detquantlib 5.7.1__tar.gz → 5.8.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 (36) hide show
  1. {detquantlib-5.7.1 → detquantlib-5.8.0}/PKG-INFO +7 -1
  2. {detquantlib-5.7.1 → detquantlib-5.8.0}/README.md +6 -0
  3. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/data/__init__.py +3 -0
  4. detquantlib-5.8.0/detquantlib/data/databases/azure_client.py +655 -0
  5. {detquantlib-5.7.1 → detquantlib-5.8.0}/pyproject.toml +1 -1
  6. {detquantlib-5.7.1 → detquantlib-5.8.0}/LICENSE.txt +0 -0
  7. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/__init__.py +0 -0
  8. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/assets/__init__.py +0 -0
  9. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/assets/battery.py +0 -0
  10. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/assets/wind_turbine.py +0 -0
  11. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/converters/__init__.py +0 -0
  12. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/converters/definitions.py +0 -0
  13. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/converters/energy.py +0 -0
  14. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/converters/helpers.py +0 -0
  15. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/converters/price.py +0 -0
  16. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/data/databases/detdatabase.py +0 -0
  17. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/data/databases/helpers.py +0 -0
  18. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/data/databases/snowflake_client.py +0 -0
  19. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/data/entsoe/entsoe.py +0 -0
  20. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/data/sftp/sftp.py +0 -0
  21. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/data/tennet/tennet_client.py +0 -0
  22. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/dates/__init__.py +0 -0
  23. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/dates/dates.py +0 -0
  24. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/figures/__init__.py +0 -0
  25. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/figures/plotly_figures.py +0 -0
  26. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/forecasting/__init__.py +0 -0
  27. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/forecasting/forecasting.py +0 -0
  28. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/outputs/__init__.py +0 -0
  29. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/outputs/outputs_interface.py +0 -0
  30. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/stats/__init__.py +0 -0
  31. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/stats/data_analysis.py +0 -0
  32. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/tradable_products/__init__.py +0 -0
  33. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/tradable_products/tradable_products.py +0 -0
  34. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/utils/__init__.py +0 -0
  35. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/utils/logging.py +0 -0
  36. {detquantlib-5.7.1 → detquantlib-5.8.0}/detquantlib/utils/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detquantlib
3
- Version: 5.7.1
3
+ Version: 5.8.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
@@ -84,6 +84,12 @@ poetry run python docs.py
84
84
 
85
85
  Classes:
86
86
 
87
+ - `AzureClient`:
88
+ - `from detquantlib.data import AzureClient`
89
+ - `from detquantlib.data.databases.azure_client import AzureClient`
90
+ - `AzureConfig`:
91
+ - `from detquantlib.data import AzureConfig`
92
+ - `from detquantlib.data.databases.azure_client import AzureConfig`
87
93
  - `BatteryState`:
88
94
  - `from detquantlib.assets import BatteryState`
89
95
  - `from detquantlib.assets.battery import BatteryState`
@@ -54,6 +54,12 @@ poetry run python docs.py
54
54
 
55
55
  Classes:
56
56
 
57
+ - `AzureClient`:
58
+ - `from detquantlib.data import AzureClient`
59
+ - `from detquantlib.data.databases.azure_client import AzureClient`
60
+ - `AzureConfig`:
61
+ - `from detquantlib.data import AzureConfig`
62
+ - `from detquantlib.data.databases.azure_client import AzureConfig`
57
63
  - `BatteryState`:
58
64
  - `from detquantlib.assets import BatteryState`
59
65
  - `from detquantlib.assets.battery import BatteryState`
@@ -1,3 +1,4 @@
1
+ from .databases.azure_client import AzureClient, AzureConfig
1
2
  from .databases.detdatabase import DetDatabase
2
3
  from .databases.helpers import *
3
4
  from .databases.snowflake_client import SnowflakeClient, SnowflakeConfig
@@ -6,6 +7,8 @@ from .sftp.sftp import Sftp
6
7
  from .tennet.tennet_client import TennetAPIError, TennetClient
7
8
 
8
9
  __all__ = [
10
+ "AzureClient",
11
+ "AzureConfig",
9
12
  "DetDatabase",
10
13
  "Entsoe",
11
14
  "Sftp",
@@ -0,0 +1,655 @@
1
+ """
2
+ Client for working with an Azure SQL database from Python.
3
+
4
+ This module provides a configuration dataclass and a client class that wrap ``pyodbc`` and
5
+ ``SQLAlchemy`` with a small, safe API for interacting with Azure SQL Database (or any SQL Server
6
+ instance reachable through the Microsoft ODBC driver). Data is 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
16
+
17
+ # Third-party packages
18
+ import pandas as pd
19
+ import pyodbc
20
+ from sqlalchemy import Engine, create_engine, inspect, text
21
+ from sqlalchemy.exc import SQLAlchemyError
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _DEFAULT_DRIVER = "ODBC Driver 18 for SQL Server"
26
+ _MAX_IDENTIFIER_LENGTH = 128
27
+ _ODBC_VALUE_NEEDS_BRACES_RE = re.compile(r"[\s;{}=]")
28
+
29
+
30
+ @dataclass
31
+ class AzureConfig:
32
+ """
33
+ Connection settings for an Azure SQL database.
34
+
35
+ Exactly one authentication method is used, chosen in this order of precedence:
36
+ - ``authentication`` (i.e. Microsoft Entra ID, formerly Azure Active Directory)
37
+ - ``user`` and ``password`` (i.e. SQL authentication)
38
+
39
+ Attributes:
40
+ server: Server address, e.g. ``"myserver.database.windows.net"``. A protocol prefix and
41
+ port can be included, e.g. ``"tcp:myserver.database.windows.net,1433"``.
42
+ database: Name of the default database, used by operations that do not specify one. Can
43
+ be left unset if every operation passes a database explicitly.
44
+ user: Login name. For SQL authentication, the SQL login. With ``authentication``, its
45
+ meaning depends on the method, e.g. the user principal name for
46
+ ``"ActiveDirectoryPassword"`` or ``"ActiveDirectoryInteractive"``, the application
47
+ (client) ID for ``"ActiveDirectoryServicePrincipal"``, or the client ID of a
48
+ user-assigned identity for ``"ActiveDirectoryMsi"``.
49
+ password: Password for SQL authentication, or the secret for ``authentication`` methods
50
+ that need one (e.g. the client secret for ``"ActiveDirectoryServicePrincipal"``).
51
+ authentication: Microsoft Entra ID authentication method passed to the ODBC driver, e.g.
52
+ ``"ActiveDirectoryInteractive"`` for browser-based sign-in with MFA,
53
+ ``"ActiveDirectoryServicePrincipal"`` or ``"ActiveDirectoryMsi"``.
54
+ driver: Name of the installed ODBC driver.
55
+ extra: Additional ODBC connection string attributes, e.g.
56
+ ``{"TrustServerCertificate": "yes", "Connection Timeout": 60}``. They override the
57
+ attributes built from the other fields.
58
+ """
59
+
60
+ server: Optional[str] = None
61
+ database: Optional[str] = None
62
+ user: Optional[str] = None
63
+ password: Optional[str] = None
64
+ authentication: Optional[str] = None
65
+ driver: str = _DEFAULT_DRIVER
66
+ extra: dict[str, Any] = field(default_factory=dict)
67
+
68
+ @classmethod
69
+ def from_env(cls, prefix: str = "AZURE_SQL_") -> "AzureConfig":
70
+ """
71
+ Builds a config from environment variables.
72
+
73
+ Reads variables named ``<prefix><FIELD>``, e.g. ``AZURE_SQL_SERVER``,
74
+ ``AZURE_SQL_DATABASE``, ``AZURE_SQL_USER``, ``AZURE_SQL_PASSWORD``,
75
+ ``AZURE_SQL_AUTHENTICATION`` and ``AZURE_SQL_DRIVER``. Unset variables become ``None``,
76
+ except ``AZURE_SQL_DRIVER``, which falls back to ``"ODBC Driver 18 for SQL Server"``.
77
+
78
+ Args:
79
+ prefix: Prefix prepended to each variable name.
80
+
81
+ Returns:
82
+ An ``AzureConfig`` populated from the environment.
83
+ """
84
+ g = lambda name: os.getenv(f"{prefix}{name}")
85
+ return cls(
86
+ server=g("SERVER"),
87
+ database=g("DATABASE"),
88
+ user=g("USER"),
89
+ password=g("PASSWORD"),
90
+ authentication=g("AUTHENTICATION"),
91
+ driver=g("DRIVER") or _DEFAULT_DRIVER,
92
+ )
93
+
94
+
95
+ class AzureClient:
96
+ """
97
+ Client for interacting with an Azure SQL database.
98
+
99
+ Wraps SQLAlchemy engines on top of ``pyodbc`` and exposes methods for running SQL and
100
+ exchanging data as pandas DataFrames. The class can be used as a context manager so the
101
+ connections are closed automatically.
102
+
103
+ Every operation targets ``config.database`` by default, or another database on the same server
104
+ if one is specified. Azure SQL Database does not support cross-database queries (or the
105
+ ``USE`` statement), so the client keeps one engine per database. Each engine is created
106
+ lazily on first use and pools its connections, so they are reused across operations.
107
+
108
+ Query parameters use SQLAlchemy's named placeholder style, e.g. ``:status``.
109
+
110
+ Attributes:
111
+ config: The connection settings used by this client.
112
+
113
+ Examples:
114
+ Example 1: Loading a table
115
+ >>> with AzureClient() as client:
116
+ ... customers = client.load_table("dbo.Customers", columns=["Id", "Name"], limit=100)
117
+
118
+ Example 2: Standard query method
119
+ >>> with AzureClient() as client:
120
+ ... df = client.query(
121
+ ... "SELECT * FROM dbo.Orders WHERE Status = :status", {"status": "SHIPPED"}
122
+ ... )
123
+
124
+ Example 3: Targeting another database on the same server
125
+ >>> with AzureClient() as client:
126
+ ... df = client.query("SELECT * FROM dbo.Positions", database="Risk")
127
+ ... p1 = client.load_table("dbo.Prices", database="Trading")
128
+ ... p2 = client.load_table("Trading.dbo.Prices") # DB.SCHEMA.TABLE format
129
+
130
+ Example 4: Batch query method to stream a large table in chunks
131
+ >>> with AzureClient(AzureConfig.from_env()) as client:
132
+ ... for chunk in client.query_batches("SELECT * FROM dbo.BigTable"):
133
+ ... process(chunk)
134
+
135
+ Example 5: Writing a DataFrame to a table, creating the table if needed
136
+ >>> with AzureClient() as client:
137
+ ... client.write_table(df, "dbo.Orders", auto_create_table=True)
138
+
139
+ Example 6: Running a statement that does not return data
140
+ >>> with AzureClient() as client:
141
+ ... client.execute("DELETE FROM dbo.Orders WHERE Status = :s", {"s": "CANCELLED"})
142
+ """
143
+
144
+ def __init__(self, config: Optional[AzureConfig] = None) -> None:
145
+ """
146
+ Initializes the client without opening a connection.
147
+
148
+ Args:
149
+ config: Connection settings. If ``None``, settings are read from environment variables
150
+ via ``AzureConfig.from_env()``.
151
+ """
152
+ self.config = config or AzureConfig.from_env()
153
+ self._engines: dict[str, Engine] = {}
154
+
155
+ # ---------------------------------------------------------------------------------------------
156
+ # Connection management
157
+ # ---------------------------------------------------------------------------------------------
158
+ def _resolve_database(self, database: Optional[str]) -> str:
159
+ """
160
+ Determines which database an operation targets.
161
+
162
+ Args:
163
+ database: Database requested by the operation, or ``None`` for the default database.
164
+
165
+ Returns:
166
+ ``database`` if given, otherwise ``config.database``.
167
+
168
+ Raises:
169
+ ValueError: If neither is set, or if the name is not a valid identifier.
170
+ """
171
+ database = database or self.config.database
172
+ if not database:
173
+ raise ValueError(
174
+ "No database specified: set 'database' in the config or pass it to the method."
175
+ )
176
+ return self._check_identifier(database)
177
+
178
+ def _connection_params(self, database: Optional[str] = None) -> dict[str, Any]:
179
+ """
180
+ Builds the attributes of the ODBC connection string.
181
+
182
+ Selects a single authentication method (Entra ID or SQL authentication, in that order),
183
+ applies ``extra`` last, and drops any attributes that are ``None``.
184
+
185
+ Args:
186
+ database: Database to connect to. If ``None``, ``config.database`` is used.
187
+
188
+ Returns:
189
+ A dict of ODBC connection string attributes.
190
+
191
+ Raises:
192
+ ValueError: If ``server`` is missing, if no database is specified, or if no
193
+ credentials are configured.
194
+ """
195
+ c = self.config
196
+ if not c.server:
197
+ raise ValueError("Azure SQL 'server' is required.")
198
+
199
+ params: dict[str, Any] = {
200
+ "DRIVER": c.driver,
201
+ "SERVER": c.server,
202
+ "DATABASE": self._resolve_database(database),
203
+ "Encrypt": "yes",
204
+ }
205
+
206
+ if c.authentication:
207
+ params["Authentication"] = c.authentication
208
+ params["UID"] = c.user
209
+ params["PWD"] = c.password
210
+ elif c.user and c.password:
211
+ params["UID"] = c.user
212
+ params["PWD"] = c.password
213
+ else:
214
+ raise ValueError("No credentials provided: set user and password, or authentication.")
215
+
216
+ params.update(c.extra)
217
+ return {k: v for k, v in params.items() if v is not None}
218
+
219
+ @staticmethod
220
+ def _to_connection_string(params: dict[str, Any]) -> str:
221
+ """
222
+ Formats attributes as an ODBC connection string.
223
+
224
+ Values containing whitespace or any of ``;{}=`` are wrapped in braces, with closing braces
225
+ doubled, so that they are passed to the driver verbatim.
226
+
227
+ Args:
228
+ params: ODBC connection string attributes.
229
+
230
+ Returns:
231
+ The connection string, e.g. ``"DRIVER={ODBC Driver 18 for SQL Server};SERVER=..."``.
232
+ """
233
+ parts = []
234
+ for key, value in params.items():
235
+ value = str(value)
236
+ if _ODBC_VALUE_NEEDS_BRACES_RE.search(value):
237
+ value = "{" + value.replace("}", "}}") + "}"
238
+ parts.append(f"{key}={value}")
239
+ return ";".join(parts)
240
+
241
+ def connect(self, database: Optional[str] = None) -> Engine:
242
+ """
243
+ Creates the engine for a database if it does not exist yet.
244
+
245
+ The connection settings are validated here, as in ``_connection_params()``. The engine
246
+ itself opens connections lazily, on the first operation that needs one.
247
+
248
+ Args:
249
+ database: Database to connect to. If ``None``, ``config.database`` is used.
250
+
251
+ Returns:
252
+ The SQLAlchemy engine for the database.
253
+ """
254
+ database = self._resolve_database(database)
255
+ if database not in self._engines:
256
+ conn_str = self._to_connection_string(self._connection_params(database))
257
+ logger.info("Connecting to Azure SQL database %s on %s", database, self.config.server)
258
+ self._engines[database] = create_engine(
259
+ "mssql+pyodbc://",
260
+ creator=lambda: pyodbc.connect(conn_str),
261
+ fast_executemany=True,
262
+ )
263
+ return self._engines[database]
264
+
265
+ def close(self) -> None:
266
+ """
267
+ Closes all pooled connections and discards the engines of all databases.
268
+
269
+ Safe to call multiple times, or when no connection was ever opened.
270
+ """
271
+ for database, engine in self._engines.items():
272
+ engine.dispose()
273
+ logger.info("Azure SQL connection to database %s closed", database)
274
+ self._engines = {}
275
+
276
+ def __enter__(self) -> "AzureClient":
277
+ """
278
+ Creates the engine of the default database, if any, when entering a ``with`` block.
279
+
280
+ Returns:
281
+ This client instance.
282
+ """
283
+ if self.config.database:
284
+ self.connect()
285
+ return self
286
+
287
+ def __exit__(
288
+ self,
289
+ exc_type: Optional[type[BaseException]],
290
+ exc: Optional[BaseException],
291
+ tb: Optional[TracebackType],
292
+ ) -> None:
293
+ """
294
+ Closes the connections when leaving a ``with`` block.
295
+
296
+ Exceptions raised inside the block are not suppressed.
297
+
298
+ Args:
299
+ exc_type: Type of the exception raised in the block, if any.
300
+ exc: The exception instance raised in the block, if any.
301
+ tb: The traceback of the exception, if any.
302
+ """
303
+ self.close()
304
+
305
+ # ---------------------------------------------------------------------------------------------
306
+ # Reading data
307
+ # ---------------------------------------------------------------------------------------------
308
+ def query(
309
+ self,
310
+ sql: str,
311
+ params: Optional[dict[str, Any]] = None,
312
+ database: Optional[str] = None,
313
+ ) -> pd.DataFrame:
314
+ """
315
+ Runs a SQL query and returns the full result as a DataFrame.
316
+
317
+ Always pass user-supplied values through ``params`` (``:name`` placeholders) rather than
318
+ formatting them into the SQL string, to avoid SQL injection. A literal colon followed by a
319
+ word, e.g. inside a string literal, must be escaped as ``\\:``.
320
+
321
+ Args:
322
+ sql: The SQL statement to execute.
323
+ params: Values bound to the named placeholders in ``sql``.
324
+ database: Database to run the query in. If ``None``, ``config.database`` is used.
325
+
326
+ Returns:
327
+ A DataFrame containing every row returned by the query. Decimal values are converted
328
+ to floats, as in ``pandas.read_sql``.
329
+
330
+ Raises:
331
+ SQLAlchemyError: If the query fails. The error is logged before being re-raised.
332
+ """
333
+ engine = self.connect(database)
334
+ try:
335
+ with engine.connect() as conn:
336
+ result = conn.execute(text(sql), params or {})
337
+ cols = list(result.keys())
338
+ df = pd.DataFrame.from_records(result.fetchall(), columns=cols, coerce_float=True)
339
+ except SQLAlchemyError as e:
340
+ logger.error("Query failed: %s", e)
341
+ raise
342
+ logger.info("Loaded %d rows", len(df))
343
+ return df
344
+
345
+ def query_batches(
346
+ self,
347
+ sql: str,
348
+ params: Optional[dict[str, Any]] = None,
349
+ batch_size: int = 10_000,
350
+ database: Optional[str] = None,
351
+ ) -> Iterator[pd.DataFrame]:
352
+ """
353
+ Streams a large result set as a sequence of DataFrames.
354
+
355
+ Useful for results too large to hold in memory at once. Rows are fetched from the server
356
+ one batch at a time, and a connection is held until the iteration finishes.
357
+
358
+ Args:
359
+ sql: The SQL statement to execute.
360
+ params: Values bound to the placeholders in ``sql``, as in ``query()``.
361
+ batch_size: Maximum number of rows per batch.
362
+ database: Database to run the query in. If ``None``, ``config.database`` is used.
363
+
364
+ Yields:
365
+ DataFrames, each holding one batch of rows.
366
+ """
367
+ engine = self.connect(database)
368
+ with engine.connect() as conn:
369
+ result = conn.execute(text(sql), params or {})
370
+ cols = list(result.keys())
371
+ while rows := result.fetchmany(batch_size):
372
+ yield pd.DataFrame.from_records(rows, columns=cols, coerce_float=True)
373
+
374
+ def load_table(
375
+ self,
376
+ table: str,
377
+ columns: Optional[Sequence[str]] = None,
378
+ where: Optional[str] = None,
379
+ params: Optional[dict[str, Any]] = None,
380
+ limit: Optional[int] = None,
381
+ database: Optional[str] = None,
382
+ ) -> pd.DataFrame:
383
+ """
384
+ Loads a table, optionally filtered, into a DataFrame.
385
+
386
+ Args:
387
+ table: Table name, optionally qualified, e.g. ``"SCHEMA.TABLE"`` or
388
+ ``"DB.SCHEMA.TABLE"``. A database part selects the database to read from. Parts
389
+ may be wrapped in square brackets, e.g. ``"[META].[Commodity]"``, and are
390
+ bracket-quoted in the query either way.
391
+ columns: Columns to select, as literal names, e.g. ``"DateTime(UTC)"``. Each name is
392
+ bracket-quoted in the query. If ``None``, all columns are selected.
393
+ where: Optional SQL condition, without the ``WHERE`` keyword. Use placeholders and pass
394
+ the values via ``params``; this string is inserted into the query as-is.
395
+ params: Values bound to the placeholders in ``where``.
396
+ limit: Maximum number of rows to return.
397
+ database: Database to read from. If ``None``, the database part of ``table`` is used,
398
+ or ``config.database`` if ``table`` has none.
399
+
400
+ Returns:
401
+ A DataFrame containing the selected rows.
402
+ """
403
+ database, parts = self._split_table_name(table, database)
404
+ table_sql = ".".join(self._quote_identifier(p) for p in parts)
405
+ cols_sql = ", ".join(self._quote_identifier(c) for c in columns) if columns else "*"
406
+ top_sql = f"TOP ({int(limit)}) " if limit is not None else ""
407
+ sql = f"SELECT {top_sql}{cols_sql} FROM {table_sql}"
408
+ if where:
409
+ sql += f" WHERE {where}"
410
+ return self.query(sql, params, database=database)
411
+
412
+ def list_tables(
413
+ self, schema: Optional[str] = None, database: Optional[str] = None
414
+ ) -> pd.DataFrame:
415
+ """
416
+ Lists the tables visible in a database.
417
+
418
+ Views are not included.
419
+
420
+ Args:
421
+ schema: Schema to inspect, e.g. ``"dbo"``. If ``None``, tables in all schemas are
422
+ listed.
423
+ database: Database to inspect. If ``None``, ``config.database`` is used.
424
+
425
+ Returns:
426
+ A DataFrame with one row per table, sorted by schema and table name, using the columns
427
+ of the ``INFORMATION_SCHEMA.TABLES`` view.
428
+
429
+ Raises:
430
+ ValueError: If ``schema`` is not a valid, unqualified schema name.
431
+ """
432
+ sql = "SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE'"
433
+ params = None
434
+ if schema:
435
+ parts = self._split_name(schema)
436
+ if len(parts) > 1:
437
+ raise ValueError(f"Invalid schema name: {schema!r}. Expected an unqualified name.")
438
+ sql += " AND TABLE_SCHEMA = :schema"
439
+ params = {"schema": parts[0]}
440
+ sql += " ORDER BY TABLE_SCHEMA, TABLE_NAME"
441
+ return self.query(sql, params, database=database)
442
+
443
+ # ---------------------------------------------------------------------------------------------
444
+ # Writing data
445
+ # ---------------------------------------------------------------------------------------------
446
+ def write_table(
447
+ self,
448
+ df: pd.DataFrame,
449
+ table: str,
450
+ overwrite: bool = False,
451
+ auto_create_table: bool = False,
452
+ chunk_size: Optional[int] = None,
453
+ database: Optional[str] = None,
454
+ ) -> int:
455
+ """
456
+ Writes a DataFrame to a table.
457
+
458
+ Data is inserted in bulk with pandas' ``to_sql`` using pyodbc's ``fast_executemany``, which
459
+ is much faster than row-by-row inserts. The whole write runs in a single transaction: if
460
+ any part fails, the table is left unchanged.
461
+
462
+ Args:
463
+ df: The data to write. The index is not written. Unless the table is created by this
464
+ call, column names must match the table's columns.
465
+ table: Table name, optionally qualified, e.g. ``"SCHEMA.TABLE"`` or
466
+ ``"DB.SCHEMA.TABLE"``. A database part selects the database to write to. Parts may
467
+ be wrapped in square brackets. If no schema is given, the user's default schema
468
+ (usually ``dbo``) is used.
469
+ overwrite: If ``True``, the existing rows are replaced by ``df``. If ``False``, ``df``
470
+ is appended. Combined with ``auto_create_table``, the table itself is recreated
471
+ with column types inferred from ``df``.
472
+ auto_create_table: If ``True``, the table is created if it does not exist, with column
473
+ types inferred from ``df``.
474
+ chunk_size: Number of rows per insert batch. If ``None``, all rows are sent at once.
475
+ database: Database to write to. If ``None``, the database part of ``table`` is used,
476
+ or ``config.database`` if ``table`` has none.
477
+
478
+ Returns:
479
+ The number of rows written. If ``df`` is empty, nothing is written, the table is left
480
+ unchanged and ``0`` is returned.
481
+
482
+ Raises:
483
+ ValueError: If the table does not exist and ``auto_create_table`` is ``False``. Also
484
+ raised for invalid table, column or database names, as in ``_split_table_name()``.
485
+ SQLAlchemyError: If the write fails. The error is logged before being re-raised.
486
+ """
487
+ database, parts = self._split_table_name(table, database)
488
+ for col in df.columns:
489
+ self._check_identifier(str(col))
490
+
491
+ if df.empty:
492
+ logger.warning("DataFrame is empty; nothing written to %s", table)
493
+ return 0
494
+
495
+ # Pad the qualifier on the left, so "T" -> (None, T) and "S.T" -> (S, T)
496
+ schema, table_name = ([None] + parts)[-2:]
497
+ table_sql = ".".join(self._quote_identifier(p) for p in parts)
498
+
499
+ engine = self.connect(database)
500
+ # pandas' to_sql always creates missing tables, so the table must be checked beforehand
501
+ if not auto_create_table and not inspect(engine).has_table(table_name, schema=schema):
502
+ raise ValueError(
503
+ f"Table {table} does not exist. Set auto_create_table=True to create it."
504
+ )
505
+ try:
506
+ with engine.begin() as conn:
507
+ if overwrite and not auto_create_table:
508
+ conn.execute(text(f"DELETE FROM {table_sql}"))
509
+ df.to_sql(
510
+ table_name,
511
+ conn,
512
+ schema=schema,
513
+ if_exists="replace" if overwrite and auto_create_table else "append",
514
+ index=False,
515
+ chunksize=chunk_size,
516
+ )
517
+ except SQLAlchemyError as e:
518
+ logger.error("Write to %s failed: %s", table, e)
519
+ raise
520
+ logger.info("Wrote %d rows to %s", len(df), table)
521
+ return len(df)
522
+
523
+ def execute(
524
+ self,
525
+ sql: str,
526
+ params: Optional[dict[str, Any]] = None,
527
+ database: Optional[str] = None,
528
+ ) -> Optional[int]:
529
+ """
530
+ Runs a SQL statement that does not return data.
531
+
532
+ Intended for statements such as ``INSERT``, ``UPDATE``, ``DELETE``, ``MERGE`` or
533
+ ``CREATE TABLE``. Always pass user-supplied values through ``params``, as in ``query()``.
534
+ The statement runs in its own transaction, which is committed if it succeeds and rolled
535
+ back if it fails.
536
+
537
+ Args:
538
+ sql: The SQL statement to execute.
539
+ params: Values bound to the placeholders in ``sql``, as in ``query()``.
540
+ database: Database to run the statement in. If ``None``, ``config.database`` is used.
541
+
542
+ Returns:
543
+ The number of rows affected, as reported by the driver, or ``None`` if unavailable
544
+ (e.g. for DDL statements, or when ``SET NOCOUNT ON`` is in effect).
545
+
546
+ Raises:
547
+ SQLAlchemyError: If the statement fails. The error is logged before being re-raised.
548
+ """
549
+ engine = self.connect(database)
550
+ try:
551
+ with engine.begin() as conn:
552
+ rowcount = conn.execute(text(sql), params or {}).rowcount
553
+ except SQLAlchemyError as e:
554
+ logger.error("Statement failed: %s", e)
555
+ raise
556
+ if rowcount is not None and rowcount < 0:
557
+ rowcount = None
558
+ logger.info("Statement affected %s rows", rowcount)
559
+ return rowcount
560
+
561
+ # ---------------------------------------------------------------------------------------------
562
+ # Identifier handling
563
+ # ---------------------------------------------------------------------------------------------
564
+ @staticmethod
565
+ def _check_identifier(name: str) -> str:
566
+ """
567
+ Validates a SQL Server identifier, as a literal name without square brackets.
568
+
569
+ Any character is allowed, since identifiers are always bracket-quoted before being placed
570
+ in a query (see ``_quote_identifier()``). Only the length is checked.
571
+
572
+ Args:
573
+ name: The identifier to validate.
574
+
575
+ Returns:
576
+ The identifier, unchanged.
577
+
578
+ Raises:
579
+ ValueError: If ``name`` is empty or longer than 128 characters.
580
+ """
581
+ if not name or len(name) > _MAX_IDENTIFIER_LENGTH:
582
+ raise ValueError(f"Invalid Azure SQL identifier: {name!r}")
583
+ return name
584
+
585
+ @staticmethod
586
+ def _quote_identifier(name: str) -> str:
587
+ """
588
+ Bracket-quotes an identifier so it can be safely placed in a query.
589
+
590
+ Guards against SQL injection in table, schema and column names, which cannot be passed as
591
+ bind parameters. Closing brackets in the name are escaped by doubling them, as done by
592
+ SQL Server's ``QUOTENAME`` function. The name is validated with ``_check_identifier()``.
593
+
594
+ Args:
595
+ name: The identifier, as a literal name without square brackets.
596
+
597
+ Returns:
598
+ The quoted identifier, e.g. ``"[DateTime(UTC)]"``.
599
+ """
600
+ return "[" + AzureClient._check_identifier(name).replace("]", "]]") + "]"
601
+
602
+ @staticmethod
603
+ def _split_name(name: str) -> list[str]:
604
+ """
605
+ Splits a dot-separated name into its parts, e.g. ``"SCHEMA.TABLE"``.
606
+
607
+ Parts may be wrapped in square brackets, e.g. ``"[META].[Commodity]"``, in which case the
608
+ brackets are removed. Dots inside brackets are not supported. Each part is validated with
609
+ ``_check_identifier()``.
610
+
611
+ Args:
612
+ name: The name to split.
613
+
614
+ Returns:
615
+ The parts of the name, without square brackets.
616
+ """
617
+ parts = []
618
+ for part in name.split("."):
619
+ if len(part) >= 2 and part.startswith("[") and part.endswith("]"):
620
+ part = part[1:-1].replace("]]", "]")
621
+ parts.append(AzureClient._check_identifier(part))
622
+ return parts
623
+
624
+ @staticmethod
625
+ def _split_table_name(table: str, database: Optional[str]) -> tuple[Optional[str], list[str]]:
626
+ """
627
+ Splits a table name of the form ``[DB.][SCHEMA.]TABLE`` into a database and the rest.
628
+
629
+ Azure SQL Database does not accept database parts in queries, so the database part is
630
+ removed from the name and used to select the engine instead.
631
+
632
+ Args:
633
+ table: The table name, as in ``_split_name()``.
634
+ database: Database passed explicitly to the calling method, or ``None``.
635
+
636
+ Returns:
637
+ A tuple with the database to use (``None`` for ``config.database``) and the
638
+ remaining one or two parts of the table name.
639
+
640
+ Raises:
641
+ ValueError: If the name has more than three parts, or if its database part
642
+ conflicts with ``database``.
643
+ """
644
+ parts = AzureClient._split_name(table)
645
+ if len(parts) > 3:
646
+ raise ValueError(f"Invalid table name: {table!r}. Expected at most DB.SCHEMA.TABLE.")
647
+ if len(parts) == 3:
648
+ name_database, *parts = parts
649
+ if database is not None and database.casefold() != name_database.casefold():
650
+ raise ValueError(
651
+ f"Table name {table!r} refers to database {name_database!r}, "
652
+ f"which conflicts with database={database!r}."
653
+ )
654
+ database = name_database
655
+ return database, parts
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "detquantlib"
3
- version = "5.7.1"
3
+ version = "5.8.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