Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ddcdatabases/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
from .core.certs import SSLCertificateError
from .core.operations import DBUtils, DBUtilsAsync
from .core.persistent import PersistentConnectionConfig, close_all_persistent_connections
from importlib.metadata import version
Expand All @@ -7,6 +8,7 @@
"DBUtils",
"DBUtilsAsync",
"PersistentConnectionConfig",
"SSLCertificateError",
"close_all_persistent_connections",
]

Expand Down
6 changes: 6 additions & 0 deletions ddcdatabases/core/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import sqlalchemy as sa
from .certs import verify_cert_paths
from .configs import BaseOperationRetryConfig, BaseRetryConfig
from .retry import retry_operation, retry_operation_async
from collections.abc import AsyncGenerator, Generator
Expand All @@ -19,6 +20,7 @@ class BaseConnection:
__slots__ = (
"connection_url",
"engine_args",
"driver_cert_paths",
"autoflush",
"expire_on_commit",
"sync_driver",
Expand All @@ -42,9 +44,11 @@ def __init__(
connection_retry_config: BaseRetryConfig | None = None,
operation_retry_config: BaseOperationRetryConfig | None = None,
logger: Any = None,
driver_cert_paths: tuple[tuple[str | None, str], ...] = (),
) -> None:
self.connection_url = connection_url
self.engine_args = engine_args
self.driver_cert_paths = driver_cert_paths
self.autoflush = autoflush
self.expire_on_commit = expire_on_commit
self.sync_driver = sync_driver
Expand Down Expand Up @@ -111,13 +115,15 @@ async def __aexit__(

@contextmanager
def _get_engine(self) -> Generator[Engine, None, None]:
verify_cert_paths(self.driver_cert_paths)
_connection_url = URL.create(drivername=self.sync_driver, **self.connection_url)
_engine = create_engine(url=_connection_url, **self.engine_args)
yield _engine
_engine.dispose()

@asynccontextmanager
async def _get_async_engine(self) -> AsyncGenerator[AsyncEngine, None]:
verify_cert_paths(self.driver_cert_paths)
_connection_url = URL.create(drivername=self.async_driver, **self.connection_url)
_engine = create_async_engine(url=_connection_url, **self.engine_args)
yield _engine
Expand Down
56 changes: 56 additions & 0 deletions ddcdatabases/core/certs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""
Construction of the client SSL contexts used by the async drivers.
"""

import os
import ssl
from .constants import CA_CERT_LABEL, MINIMUM_TLS_VERSION
from collections.abc import Iterable


class SSLCertificateError(OSError):
"""A configured certificate path could not be loaded"""


def build_client_ssl_context(
ca_cert_path: str,
client_cert_path: str | None = None,
client_key_path: str | None = None,
minimum_version: ssl.TLSVersion = MINIMUM_TLS_VERSION,
) -> ssl.SSLContext:
"""Build a client SSL context, naming the file when one cannot be loaded"""

context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) # noqa: S4423
context.minimum_version = minimum_version

try:
context.load_verify_locations(cafile=ca_cert_path)
except OSError as err:
raise SSLCertificateError(f"{CA_CERT_LABEL} unusable: {ca_cert_path} | {err}") from err

if client_cert_path and client_key_path:
try:
context.load_cert_chain(certfile=client_cert_path, keyfile=client_key_path)
except OSError as err:
raise SSLCertificateError(
f"client certificate/key unusable: {client_cert_path}, {client_key_path} | {err}"
) from err

return context


def verify_cert_paths(entries: Iterable[tuple[str | None, str]]) -> None:
"""Raise SSLCertificateError naming every configured path that is not usable"""

problems: list[str] = []
for path, label in entries:
if not path:
continue
if not os.path.exists(path):
problems.append(f"{label} missing: {path}")
continue
required = os.R_OK | os.X_OK if os.path.isdir(path) else os.R_OK
if not os.access(path, required):
problems.append(f"{label} not readable: {path}")
if problems:
raise SSLCertificateError("; ".join(problems))
12 changes: 7 additions & 5 deletions ddcdatabases/core/configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def merge_config_with_settings[C](
field_map: Dict mapping config field names to settings attribute names.
If None, config field names must match settings attribute names.
"""

override = override or config_cls()
field_map = field_map or {}
kwargs = {}
Expand All @@ -47,10 +48,11 @@ def merge_config_with_settings[C](

def _validate_retry_config(
max_retries: int | None,
initial_retry_delay: float | None,
max_retry_delay: float | None,
initial_retry_delay: float | int | None,
max_retry_delay: float | int | None,
) -> None:
"""Validation for retry configs to avoid super() issues with frozen slotted dataclasses"""

if max_retries is not None and max_retries < 0:
raise ValueError("max_retries must be non-negative")
if initial_retry_delay is not None and initial_retry_delay < 0:
Expand Down Expand Up @@ -109,16 +111,16 @@ class BaseSessionConfig:
class BaseRetryConfig:
enable_retry: bool | None = None
max_retries: int | None = None
initial_retry_delay: float | None = None
max_retry_delay: float | None = None
initial_retry_delay: float | int | None = None
max_retry_delay: float | int | None = None

def __post_init__(self) -> None:
_validate_retry_config(self.max_retries, self.initial_retry_delay, self.max_retry_delay)


@dataclass(frozen=True, slots=True)
class BaseOperationRetryConfig(BaseRetryConfig):
jitter: float | None = None
jitter: float | int | None = None

def __post_init__(self) -> None:
_validate_retry_config(self.max_retries, self.initial_retry_delay, self.max_retry_delay)
Expand Down
15 changes: 12 additions & 3 deletions ddcdatabases/core/constants.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import ssl
from typing import Final

# Lowest TLS version
MINIMUM_TLS_VERSION: Final = ssl.TLSVersion.TLSv1_3

# SSL Modes
POSTGRESQL_SSL_MODES: Final[frozenset[str]] = frozenset(
POSTGRESQL_SSL_MODES: Final[frozenset[str]] = frozenset[str](
{
"disable",
"allow",
Expand All @@ -12,7 +16,7 @@
}
)

MYSQL_SSL_MODES: Final[frozenset[str]] = frozenset(
MYSQL_SSL_MODES: Final[frozenset[str]] = frozenset[str](
{
"DISABLED",
"PREFERRED",
Expand All @@ -22,8 +26,13 @@
}
)

# Labels naming each certificate path in SSLCertificateError messages
CA_CERT_LABEL: Final = "CA certificate"
CLIENT_CERT_LABEL: Final = "client certificate"
CLIENT_KEY_LABEL: Final = "client key"

# Connection error keywords for retry logic
CONNECTION_ERROR_KEYWORDS: Final[frozenset[str]] = frozenset(
CONNECTION_ERROR_KEYWORDS: Final[frozenset[str]] = frozenset[str](
{
"connection",
"connect",
Expand Down
23 changes: 20 additions & 3 deletions ddcdatabases/core/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def __init__(self, session: Session, retry_config: BaseOperationRetryConfig | No

def _execute_with_retry[T](self, operation: Callable[[], T], operation_name: str) -> T:
"""Execute an operation with retry logic if enabled."""

if self.retry_config.enable_retry:
return retry_operation(operation, self.retry_config, operation_name)
return operation()
Expand All @@ -45,7 +46,7 @@ def _fetchall_impl(self, stmt: Any, as_dict: bool = False) -> list[RowMapping] |
else:
result = cursor.mappings().all()
cursor.close()
return list(result)
return list[RowMapping](result)
except Exception as e:
self.session.rollback()
_logger.exception("fetchall failed")
Expand All @@ -65,6 +66,7 @@ def fetchall(self, stmt: Any, as_dict: bool = False) -> list[RowMapping] | list[
Raises:
DBFetchAllException: If query execution fails
"""

return self._execute_with_retry(lambda: self._fetchall_impl(stmt, as_dict), "fetchall")

def _fetchvalue_impl(self, stmt: Any) -> Any:
Expand Down Expand Up @@ -95,6 +97,7 @@ def fetchvalue(self, stmt: Any) -> Any:
Raises:
DBFetchValueException: If query execution fails
"""

return self._execute_with_retry(lambda: self._fetchvalue_impl(stmt), "fetchvalue")

def _insert_impl(self, stmt: Any) -> Any:
Expand All @@ -121,6 +124,7 @@ def insert(self, stmt: Any) -> Any:
Raises:
DBInsertSingleException: If insert operation fails
"""

return self._execute_with_retry(lambda: self._insert_impl(stmt), "insert")

def _insertbulk_impl[T](self, model: type[T], list_data: Sequence[dict[str, Any]], batch_size: int = 1000) -> None:
Expand Down Expand Up @@ -153,6 +157,7 @@ def insertbulk[T](self, model: type[T], list_data: Sequence[dict[str, Any]], bat
Raises:
DBInsertBulkException: If bulk insert operation fails
"""

return self._execute_with_retry(lambda: self._insertbulk_impl(model, list_data, batch_size), "insertbulk")

def _deleteall_impl[T](self, model: type[T]) -> None:
Expand All @@ -176,6 +181,7 @@ def deleteall[T](self, model: type[T]) -> None:
Raises:
DBDeleteAllDataException: If delete operation fails
"""

return self._execute_with_retry(lambda: self._deleteall_impl(model), "deleteall")

def _execute_impl(self, stmt: Any) -> None:
Expand All @@ -197,6 +203,7 @@ def execute(self, stmt: Any) -> None:
Raises:
DBExecuteException: If statement execution fails
"""

return self._execute_with_retry(lambda: self._execute_impl(stmt), "execute")


Expand All @@ -209,6 +216,7 @@ def __init__(self, session: AsyncSession, retry_config: BaseOperationRetryConfig

async def _execute_with_retry(self, operation: Callable[[], Any], operation_name: str) -> Any:
"""Execute an async operation with retry logic if enabled."""

if self.retry_config.enable_retry:
return await retry_operation_async(operation, self.retry_config, operation_name)
return await operation()
Expand All @@ -224,7 +232,7 @@ async def _fetchall_impl(self, stmt: Any, as_dict: bool = False) -> list[RowMapp
else:
result = cursor.mappings().all()
cursor.close()
return list(result)
return list[RowMapping](result)
except Exception as e:
await self.session.rollback()
_logger.exception("async fetchall failed")
Expand All @@ -244,6 +252,7 @@ async def fetchall(self, stmt: Any, as_dict: bool = False) -> list[RowMapping] |
Raises:
DBFetchAllException: If query execution fails
"""

return await self._execute_with_retry(lambda: self._fetchall_impl(stmt, as_dict), "fetchall")

async def _fetchvalue_impl(self, stmt: Any) -> Any:
Expand Down Expand Up @@ -274,6 +283,7 @@ async def fetchvalue(self, stmt: Any) -> Any:
Raises:
DBFetchValueException: If query execution fails
"""

return await self._execute_with_retry(lambda: self._fetchvalue_impl(stmt), "fetchvalue")

async def _insert_impl(self, stmt: Any) -> Any:
Expand All @@ -300,10 +310,14 @@ async def insert(self, stmt: Any) -> Any:
Raises:
DBInsertSingleException: If insert operation fails
"""

return await self._execute_with_retry(lambda: self._insert_impl(stmt), "insert")

async def _insertbulk_impl[T](
self, model: type[T], list_data: Sequence[dict[str, Any]], batch_size: int = 1000
self,
model: type[T],
list_data: Sequence[dict[str, Any]],
batch_size: int = 1000,
) -> None:
try:
if not list_data:
Expand Down Expand Up @@ -338,6 +352,7 @@ async def insertbulk[T](self, model: type[T], list_data: Sequence[dict[str, Any]
Raises:
DBInsertBulkException: If bulk insert operation fails
"""

return await self._execute_with_retry(lambda: self._insertbulk_impl(model, list_data, batch_size), "insertbulk")

async def _deleteall_impl[T](self, model: type[T]) -> None:
Expand All @@ -362,6 +377,7 @@ async def deleteall[T](self, model: type[T]) -> None:
Raises:
DBDeleteAllDataException: If delete operation fails
"""

return await self._execute_with_retry(lambda: self._deleteall_impl(model), "deleteall")

async def _execute_impl(self, stmt: Any) -> None:
Expand All @@ -383,4 +399,5 @@ async def execute(self, stmt: Any) -> None:
Raises:
DBExecuteException: If statement execution fails
"""

return await self._execute_with_retry(lambda: self._execute_impl(stmt), "execute")
Loading
Loading