diff --git a/CLAUDE.md b/CLAUDE.md index 27e2d71..65476a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,9 +18,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `CatalogInfo`, `NamespaceInfo`, `TableInfo`, `ColumnInfo` — shared physical metadata dataclasses 3. **Settings** (`src/mountainash_data/core/settings/`) - - Declarative per-backend descriptors (`BackendDescriptor` + `ParameterSpec` list). - - Typed discriminated-union auth via `AuthSpec` subclasses (`PasswordAuth`, `OAuth2Auth`, `IAMAuth`, …). - - Backend shell classes register themselves via `@register` and expose `to_driver_kwargs()` + `to_connection_string()`. + - Database-flavored layer over `mountainash-settings`'s `profiles` and + `auth` sub-packages. + - `BackendDescriptor` is a typed `ProfileDescriptor` subclass adding + `default_port` / `connection_string_scheme` / `ibis_dialect` / `rides_on`; + every backend is a two-line shell registered via `@register`. + - `ConnectionProfile` adds `to_driver_kwargs()` and `to_connection_string()` + on top of the generic `DescriptorProfile` base. + - `AuthSpec` subclasses (`PasswordAuth`, `OAuth2Auth`, `IAMAuth`, …) live + upstream in `mountainash_settings.auth` and are re-exported from + `mountainash_data.core.settings` for downstream compatibility. - Composite driver mappings live in `settings/adapters/.py`. 4. **Factories** (`src/mountainash_data/core/factories/`) diff --git a/src/mountainash_data/core/settings/__init__.py b/src/mountainash_data/core/settings/__init__.py index c316fe6..def26ab 100644 --- a/src/mountainash_data/core/settings/__init__.py +++ b/src/mountainash_data/core/settings/__init__.py @@ -10,6 +10,7 @@ class body is a two-line shell (``__descriptor__`` + ``__adapter__``). from .descriptor import MISSING, BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import ( + DATABASES_REGISTRY, REGISTRY, get_descriptor, get_settings_class, @@ -17,7 +18,7 @@ class body is a two-line shell (``__descriptor__`` + ``__adapter__``). ) # Auth union members -from .auth import ( +from mountainash_settings.auth import ( AuthSpec, AzureADAuth, CertificateAuth, @@ -49,7 +50,8 @@ class body is a two-line shell (``__descriptor__`` + ``__adapter__``). __all__ = [ # primitives "MISSING", "BackendDescriptor", "ParameterSpec", "ConnectionProfile", - "REGISTRY", "get_descriptor", "get_settings_class", "register", + "DATABASES_REGISTRY", "REGISTRY", + "get_descriptor", "get_settings_class", "register", # auth "AuthSpec", "NoAuth", "PasswordAuth", "TokenAuth", "JWTAuth", "OAuth2Auth", "ServiceAccountAuth", "IAMAuth", "WindowsAuth", diff --git a/src/mountainash_data/core/settings/adapters/bigquery.py b/src/mountainash_data/core/settings/adapters/bigquery.py index 58359bd..affe452 100644 --- a/src/mountainash_data/core/settings/adapters/bigquery.py +++ b/src/mountainash_data/core/settings/adapters/bigquery.py @@ -4,14 +4,14 @@ import typing as t -from mountainash_data.core.settings.auth import NoAuth, ServiceAccountAuth +from mountainash_settings.auth import NoAuth, ServiceAccountAuth if t.TYPE_CHECKING: from mountainash_data.core.settings.bigquery import BigQueryAuthSettings def build_driver_kwargs(profile: "BigQueryAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_driver_kwargs() + kwargs = profile._default_kwargs() auth = profile.auth if isinstance(auth, ServiceAccountAuth): diff --git a/src/mountainash_data/core/settings/adapters/mssql.py b/src/mountainash_data/core/settings/adapters/mssql.py index 1ea1dc5..da630ec 100644 --- a/src/mountainash_data/core/settings/adapters/mssql.py +++ b/src/mountainash_data/core/settings/adapters/mssql.py @@ -4,7 +4,7 @@ import typing as t -from mountainash_data.core.settings.auth import ( +from mountainash_settings.auth import ( AzureADAuth, PasswordAuth, WindowsAuth, @@ -15,7 +15,7 @@ def build_driver_kwargs(profile: "MSSQLAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_driver_kwargs() + kwargs = profile._default_kwargs() # Instance name → host\instance if profile.INSTANCE_NAME: diff --git a/src/mountainash_data/core/settings/adapters/mysql.py b/src/mountainash_data/core/settings/adapters/mysql.py index 7442895..0be23d7 100644 --- a/src/mountainash_data/core/settings/adapters/mysql.py +++ b/src/mountainash_data/core/settings/adapters/mysql.py @@ -10,8 +10,8 @@ def build_driver_kwargs(profile: "MySQLAuthSettings") -> dict[str, t.Any]: """Assemble driver kwargs, including ssl={} dict if any SSL fields are set.""" - kwargs = profile._default_driver_kwargs() - kwargs.update(profile._auth_to_driver_kwargs()) + kwargs = profile._default_kwargs() + kwargs.update(profile._auth_kwargs()) if profile.SSL_MODE is not None: kwargs["ssl_mode"] = str(profile.SSL_MODE) diff --git a/src/mountainash_data/core/settings/adapters/pyiceberg_rest.py b/src/mountainash_data/core/settings/adapters/pyiceberg_rest.py index 2eee130..f5d4ed8 100644 --- a/src/mountainash_data/core/settings/adapters/pyiceberg_rest.py +++ b/src/mountainash_data/core/settings/adapters/pyiceberg_rest.py @@ -4,7 +4,7 @@ import typing as t -from mountainash_data.core.settings.auth import OAuth2Auth, TokenAuth +from mountainash_settings.auth import OAuth2Auth, TokenAuth if t.TYPE_CHECKING: from mountainash_data.core.settings.pyiceberg_rest import ( @@ -13,7 +13,7 @@ def build_driver_kwargs(profile: "PyIcebergRestAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_driver_kwargs() + kwargs = profile._default_kwargs() # S3 family for field, key in [ diff --git a/src/mountainash_data/core/settings/adapters/redshift.py b/src/mountainash_data/core/settings/adapters/redshift.py index 9441609..2663d21 100644 --- a/src/mountainash_data/core/settings/adapters/redshift.py +++ b/src/mountainash_data/core/settings/adapters/redshift.py @@ -4,14 +4,14 @@ import typing as t -from mountainash_data.core.settings.auth import IAMAuth, PasswordAuth +from mountainash_settings.auth import IAMAuth, PasswordAuth if t.TYPE_CHECKING: from mountainash_data.core.settings.redshift import RedshiftAuthSettings def build_driver_kwargs(profile: "RedshiftAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_driver_kwargs() + kwargs = profile._default_kwargs() auth = profile.auth if isinstance(auth, PasswordAuth): diff --git a/src/mountainash_data/core/settings/adapters/snowflake.py b/src/mountainash_data/core/settings/adapters/snowflake.py index e2db4b4..b542772 100644 --- a/src/mountainash_data/core/settings/adapters/snowflake.py +++ b/src/mountainash_data/core/settings/adapters/snowflake.py @@ -4,7 +4,7 @@ import typing as t -from mountainash_data.core.settings.auth import ( +from mountainash_settings.auth import ( CertificateAuth, OAuth2Auth, PasswordAuth, @@ -16,7 +16,7 @@ def build_driver_kwargs(profile: "SnowflakeAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_driver_kwargs() + kwargs = profile._default_kwargs() # Session parameters session_params: dict[str, t.Any] = {} diff --git a/src/mountainash_data/core/settings/adapters/trino.py b/src/mountainash_data/core/settings/adapters/trino.py index f394249..075cf72 100644 --- a/src/mountainash_data/core/settings/adapters/trino.py +++ b/src/mountainash_data/core/settings/adapters/trino.py @@ -4,7 +4,7 @@ import typing as t -from mountainash_data.core.settings.auth import ( +from mountainash_settings.auth import ( JWTAuth, KerberosAuth, NoAuth, @@ -16,7 +16,7 @@ def build_driver_kwargs(profile: "TrinoAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_driver_kwargs() + kwargs = profile._default_kwargs() auth = profile.auth if isinstance(auth, PasswordAuth): from trino.auth import BasicAuthentication diff --git a/src/mountainash_data/core/settings/auth/__init__.py b/src/mountainash_data/core/settings/auth/__init__.py index 3d10a71..bad7c42 100644 --- a/src/mountainash_data/core/settings/auth/__init__.py +++ b/src/mountainash_data/core/settings/auth/__init__.py @@ -1,27 +1,44 @@ -"""Discriminated-union AuthSpec members.""" +"""Compatibility shim — re-exports from mountainash_settings.auth. -from .azure import AzureADAuth, WindowsAuth -from .base import AuthSpec -from .certificate import CertificateAuth -from .iam import IAMAuth -from .kerberos import KerberosAuth -from .none import NoAuth -from .oauth2 import OAuth2Auth -from .password import PasswordAuth -from .service_account import ServiceAccountAuth -from .token import JWTAuth, TokenAuth +The auth primitives (AuthSpec subclasses, auth_to_driver_kwargs) now live in +the upstream mountainash-settings package. This module re-exports everything +so existing imports of the form:: + + from mountainash_data.core.settings.auth import NoAuth + +continue to work unchanged while the rest of the codebase migrates. +""" + +from mountainash_settings.auth import ( + AUTH_TO_DRIVER_KWARGS, + AuthSpec, + AzureADAuth, + CertificateAuth, + IAMAuth, + JWTAuth, + KerberosAuth, + NoAuth, + OAuth2Auth, + PasswordAuth, + ServiceAccountAuth, + TokenAuth, + WindowsAuth, + auth_to_driver_kwargs, +) __all__ = [ + "AUTH_TO_DRIVER_KWARGS", "AuthSpec", - "NoAuth", - "PasswordAuth", - "TokenAuth", + "AzureADAuth", + "CertificateAuth", + "IAMAuth", "JWTAuth", + "KerberosAuth", + "NoAuth", "OAuth2Auth", + "PasswordAuth", "ServiceAccountAuth", - "IAMAuth", + "TokenAuth", "WindowsAuth", - "AzureADAuth", - "KerberosAuth", - "CertificateAuth", + "auth_to_driver_kwargs", ] diff --git a/src/mountainash_data/core/settings/auth/azure.py b/src/mountainash_data/core/settings/auth/azure.py deleted file mode 100644 index a9f7539..0000000 --- a/src/mountainash_data/core/settings/auth/azure.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Microsoft-centric authentication: Windows integrated + Azure AD.""" - -from __future__ import annotations - -import typing as t - -from pydantic import SecretStr - -from .base import AuthSpec - -__all__ = ["WindowsAuth", "AzureADAuth"] - - -class WindowsAuth(AuthSpec): - """Integrated Windows authentication (MSSQL).""" - - kind: t.Literal["windows"] = "windows" - username: str | None = None - domain: str | None = None - - -class AzureADAuth(AuthSpec): - """Azure Active Directory authentication (MSSQL).""" - - kind: t.Literal["azure_ad"] = "azure_ad" - tenant_id: str | None = None - client_id: str | None = None - client_secret: SecretStr | None = None - managed_identity: bool = False - msi_endpoint: str | None = None diff --git a/src/mountainash_data/core/settings/auth/base.py b/src/mountainash_data/core/settings/auth/base.py index d5d5ed5..50bce31 100644 --- a/src/mountainash_data/core/settings/auth/base.py +++ b/src/mountainash_data/core/settings/auth/base.py @@ -1,21 +1,5 @@ -"""Base class for discriminated-union auth specifications.""" +"""Compatibility shim — re-exports from mountainash_settings.auth.base.""" -from __future__ import annotations - -from pydantic import BaseModel, ConfigDict +from mountainash_settings.auth.base import AuthSpec __all__ = ["AuthSpec"] - - -class AuthSpec(BaseModel): - """Abstract tagged base for authentication specifications. - - Each concrete subclass declares its own ``kind: Literal["..."]`` field; - this base intentionally does not declare one. When composed into a - :class:`pydantic.Field` discriminated union, pydantic looks up ``kind`` - on each member, not on a shared base, so removing it here also closes - Pyright's ``reportIncompatibleVariableOverride`` warnings on every - subclass. - """ - - model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/src/mountainash_data/core/settings/auth/certificate.py b/src/mountainash_data/core/settings/auth/certificate.py deleted file mode 100644 index 5c9ff3f..0000000 --- a/src/mountainash_data/core/settings/auth/certificate.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Private-key / certificate authentication (Snowflake JWT).""" - -from __future__ import annotations - -import typing as t -from pathlib import Path - -from pydantic import SecretStr - -from .base import AuthSpec - -__all__ = ["CertificateAuth"] - - -class CertificateAuth(AuthSpec): - """Private-key signed JWT authentication (Snowflake).""" - - kind: t.Literal["certificate"] = "certificate" - private_key: SecretStr | None = None - private_key_path: Path | None = None - passphrase: SecretStr | None = None diff --git a/src/mountainash_data/core/settings/auth/dispatch.py b/src/mountainash_data/core/settings/auth/dispatch.py index acdb604..66c8d36 100644 --- a/src/mountainash_data/core/settings/auth/dispatch.py +++ b/src/mountainash_data/core/settings/auth/dispatch.py @@ -1,80 +1,5 @@ -"""Default mapping from AuthSpec instances to driver kwargs. +"""Compatibility shim — re-exports from mountainash_settings.auth.dispatch.""" -Backend adapters can override individual auth types by consulting this map or -by writing bespoke match statements. The defaults cover the common case. -""" - -from __future__ import annotations - -import typing as t - -from .base import AuthSpec -from .iam import IAMAuth -from .none import NoAuth -from .oauth2 import OAuth2Auth -from .password import PasswordAuth -from .token import JWTAuth, TokenAuth +from mountainash_settings.auth.dispatch import AUTH_TO_DRIVER_KWARGS, auth_to_driver_kwargs __all__ = ["AUTH_TO_DRIVER_KWARGS", "auth_to_driver_kwargs"] - - -def _noauth(_auth: NoAuth) -> dict[str, t.Any]: - return {} - - -def _password(auth: PasswordAuth) -> dict[str, t.Any]: - return { - "user": auth.username, - "password": auth.password.get_secret_value(), - } - - -def _token(auth: TokenAuth) -> dict[str, t.Any]: - return {"token": auth.token.get_secret_value()} - - -def _jwt(auth: JWTAuth) -> dict[str, t.Any]: - return {"token": auth.token.get_secret_value()} - - -def _oauth2(auth: OAuth2Auth) -> dict[str, t.Any]: - if auth.token is not None: - return {"token": auth.token.get_secret_value()} - if auth.client_id is not None and auth.client_secret is not None: - return {"credential": f"{auth.client_id}:{auth.client_secret.get_secret_value()}"} - return {} - - -def _iam(auth: IAMAuth) -> dict[str, t.Any]: - """Empty dict means 'use ambient AWS credentials' (env vars, instance profile, SSO).""" - out: dict[str, t.Any] = {} - if auth.role_arn is not None: - out["iam_role_arn"] = auth.role_arn - if auth.access_key_id is not None: - out["aws_access_key_id"] = auth.access_key_id - if auth.secret_access_key is not None: - out["aws_secret_access_key"] = auth.secret_access_key.get_secret_value() - if auth.session_token is not None: - out["aws_session_token"] = auth.session_token.get_secret_value() - return out - - -AUTH_TO_DRIVER_KWARGS: dict[type[AuthSpec], t.Callable[[t.Any], dict[str, t.Any]]] = { - NoAuth: _noauth, - PasswordAuth: _password, - TokenAuth: _token, - JWTAuth: _jwt, - OAuth2Auth: _oauth2, - IAMAuth: _iam, - # WindowsAuth, AzureADAuth, KerberosAuth, ServiceAccountAuth, CertificateAuth: - # no sensible default — their respective backend adapters handle mapping. -} - - -def auth_to_driver_kwargs(auth: AuthSpec) -> dict[str, t.Any]: - """Look up the default mapper for ``auth`` and produce driver kwargs. - - Raises: - KeyError: if no mapper is registered for ``type(auth)``. - """ - return AUTH_TO_DRIVER_KWARGS[type(auth)](auth) diff --git a/src/mountainash_data/core/settings/auth/iam.py b/src/mountainash_data/core/settings/auth/iam.py deleted file mode 100644 index 1a103e3..0000000 --- a/src/mountainash_data/core/settings/auth/iam.py +++ /dev/null @@ -1,22 +0,0 @@ -"""AWS IAM authentication.""" - -from __future__ import annotations - -import typing as t - -from pydantic import SecretStr - -from .base import AuthSpec - -__all__ = ["IAMAuth"] - - -class IAMAuth(AuthSpec): - """AWS IAM credentials (Redshift, S3-backed catalogs).""" - - kind: t.Literal["iam"] = "iam" - role_arn: str | None = None - access_key_id: str | None = None - secret_access_key: SecretStr | None = None - session_token: SecretStr | None = None - profile_name: str | None = None diff --git a/src/mountainash_data/core/settings/auth/kerberos.py b/src/mountainash_data/core/settings/auth/kerberos.py deleted file mode 100644 index d090cbe..0000000 --- a/src/mountainash_data/core/settings/auth/kerberos.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Kerberos / GSSAPI authentication.""" - -from __future__ import annotations - -import typing as t -from pathlib import Path - -from .base import AuthSpec - -__all__ = ["KerberosAuth"] - - -class KerberosAuth(AuthSpec): - """Kerberos authentication (Trino, PostgreSQL via GSS).""" - - kind: t.Literal["kerberos"] = "kerberos" - service_name: str = "postgres" - principal: str | None = None - keytab: Path | None = None diff --git a/src/mountainash_data/core/settings/auth/none.py b/src/mountainash_data/core/settings/auth/none.py deleted file mode 100644 index dc6efcf..0000000 --- a/src/mountainash_data/core/settings/auth/none.py +++ /dev/null @@ -1,15 +0,0 @@ -"""The 'no authentication' variant.""" - -from __future__ import annotations - -import typing as t - -from .base import AuthSpec - -__all__ = ["NoAuth"] - - -class NoAuth(AuthSpec): - """No authentication required (SQLite, DuckDB, PySpark).""" - - kind: t.Literal["none"] = "none" diff --git a/src/mountainash_data/core/settings/auth/oauth2.py b/src/mountainash_data/core/settings/auth/oauth2.py deleted file mode 100644 index 1e82936..0000000 --- a/src/mountainash_data/core/settings/auth/oauth2.py +++ /dev/null @@ -1,23 +0,0 @@ -"""OAuth2 client-credentials / token authentication.""" - -from __future__ import annotations - -import typing as t - -from pydantic import SecretStr - -from .base import AuthSpec - -__all__ = ["OAuth2Auth"] - - -class OAuth2Auth(AuthSpec): - """OAuth2 credential set (Snowflake, Trino, PyIceberg REST).""" - - kind: t.Literal["oauth2"] = "oauth2" - client_id: str | None = None - client_secret: SecretStr | None = None - token: SecretStr | None = None - refresh_token: SecretStr | None = None - server_uri: str | None = None - scope: str | None = None diff --git a/src/mountainash_data/core/settings/auth/password.py b/src/mountainash_data/core/settings/auth/password.py deleted file mode 100644 index c885f55..0000000 --- a/src/mountainash_data/core/settings/auth/password.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Classic username + password authentication.""" - -from __future__ import annotations - -import typing as t - -from pydantic import SecretStr - -from .base import AuthSpec - -__all__ = ["PasswordAuth"] - - -class PasswordAuth(AuthSpec): - """Username + password authentication.""" - - kind: t.Literal["password"] = "password" - username: str - password: SecretStr diff --git a/src/mountainash_data/core/settings/auth/service_account.py b/src/mountainash_data/core/settings/auth/service_account.py deleted file mode 100644 index 5c74a91..0000000 --- a/src/mountainash_data/core/settings/auth/service_account.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Google-style service-account authentication.""" - -from __future__ import annotations - -import typing as t -from pathlib import Path - -from .base import AuthSpec - -__all__ = ["ServiceAccountAuth"] - - -class ServiceAccountAuth(AuthSpec): - """Google Cloud service-account key (JSON dict or file path).""" - - kind: t.Literal["service_account"] = "service_account" - info: dict[str, t.Any] | None = None - file: Path | None = None diff --git a/src/mountainash_data/core/settings/auth/token.py b/src/mountainash_data/core/settings/auth/token.py deleted file mode 100644 index a5ea882..0000000 --- a/src/mountainash_data/core/settings/auth/token.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Bearer-token and JWT authentication.""" - -from __future__ import annotations - -import typing as t - -from pydantic import SecretStr - -from .base import AuthSpec - -__all__ = ["TokenAuth", "JWTAuth"] - - -class TokenAuth(AuthSpec): - """Opaque bearer token (e.g. MotherDuck, PyIceberg REST).""" - - kind: t.Literal["token"] = "token" - token: SecretStr - - -class JWTAuth(AuthSpec): - """JSON Web Token authentication (e.g. Trino).""" - - kind: t.Literal["jwt"] = "jwt" - token: SecretStr diff --git a/src/mountainash_data/core/settings/bigquery.py b/src/mountainash_data/core/settings/bigquery.py index 0405a47..86225e4 100644 --- a/src/mountainash_data/core/settings/bigquery.py +++ b/src/mountainash_data/core/settings/bigquery.py @@ -13,7 +13,7 @@ from ..constants import CONST_DB_PROVIDER_TYPE from .adapters import bigquery as _adapter -from .auth import NoAuth, ServiceAccountAuth +from mountainash_settings.auth import NoAuth, ServiceAccountAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/descriptor.py b/src/mountainash_data/core/settings/descriptor.py index 889759a..d418ce7 100644 --- a/src/mountainash_data/core/settings/descriptor.py +++ b/src/mountainash_data/core/settings/descriptor.py @@ -1,97 +1,37 @@ -"""Declarative descriptors for backend settings. +"""Database-flavored ProfileDescriptor with typed metadata fields. -A :class:`BackendDescriptor` captures everything the generic -:class:`~mountainash_data.core.settings.profile.ConnectionProfile` base needs -to configure pydantic fields and driver mappings for a given backend. A -:class:`ParameterSpec` describes one field within a descriptor. +Retained in mountainash-data (rather than lifted to mountainash-settings) +because these fields are domain-specific: ``connection_string_scheme`` and +``ibis_dialect`` are meaningful only for SQL-like databases. """ from __future__ import annotations -import typing as t from dataclasses import dataclass -__all__ = ["MISSING", "ParameterSpec", "BackendDescriptor"] +from mountainash_settings.profiles import ( + MISSING, + ParameterSpec, + ProfileDescriptor, +) +from mountainash_settings.profiles.descriptor import _Missing - -class _Missing: - """Sentinel indicating a required (no-default) field. - - Pydantic ``Field(...)`` is emitted when a :class:`ParameterSpec` default - is this sentinel; ``Field(default=...)`` otherwise. - """ - - _instance: "t.ClassVar[_Missing | None]" = None - - def __new__(cls) -> "_Missing": - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - def __repr__(self) -> str: - return "MISSING" - - def __bool__(self) -> bool: - return False - - -MISSING: _Missing = _Missing() - - -@dataclass(frozen=True, kw_only=True) -class ParameterSpec: - """One settings field on a backend. - - Attributes: - name: Settings-facing uppercase name (e.g. ``"SSL_CERT"``). - type: Pydantic-compatible annotation (``str``, ``int | None``, enum, …). - tier: ``"core"`` or ``"advanced"`` — audit-style severity tier. - default: Default value; :data:`MISSING` means the field is required. - description: Optional docstring for generated schemas / help output. - driver_key: Driver kwarg name for 1:1 mappings (e.g. ``"sslcert"``). - ``None`` means the adapter handles it. - secret: If ``True``, wrap ``type`` as :class:`pydantic.SecretStr` and - auto-unwrap via ``.get_secret_value()`` at the kwargs boundary. - transform: Optional callable applied when emitting driver kwargs - (e.g. :class:`~pathlib.Path` → ``str``, ``bool`` → ``"0"``/``"1"``). - validator: Optional pydantic-compatible field-level validator. - """ - - name: str - type: t.Any - tier: t.Literal["core", "advanced"] - default: t.Any = MISSING - description: str = "" - driver_key: str | None = None - secret: bool = False - transform: t.Callable[[t.Any], t.Any] | None = None - validator: t.Callable[[t.Any], t.Any] | None = None +__all__ = ["MISSING", "_Missing", "BackendDescriptor", "ParameterSpec"] @dataclass(frozen=True, kw_only=True) -class BackendDescriptor: - """Immutable description of a single backend. +class BackendDescriptor(ProfileDescriptor): + """ProfileDescriptor with database-specific typed metadata. - Attributes: - name: Lowercase short name (``"postgresql"``, ``"pyiceberg_rest"``). - provider_type: Canonical provider identifier - (``CONST_DB_PROVIDER_TYPE`` member). + Extra fields: default_port: Default TCP port if the backend listens on one. - parameters: Ordered list of :class:`ParameterSpec`. - auth_modes: Tuple of :class:`~...settings.auth.base.AuthSpec` subclasses - this backend accepts. - connection_string_scheme: Scheme prefix (``"postgresql://"``) or - ``None`` if the backend does not use a URL form. + connection_string_scheme: URL scheme prefix (``"postgresql://"``) or + ``None`` if the backend has no URL form. ibis_dialect: Name of the Ibis backend if Ibis handles this backend. rides_on: Name of another backend whose Ibis path this one routes - through (e.g. ``motherduck`` → ``duckdb``). Metadata only — no - runtime behavior. + through (e.g. ``motherduck`` -> ``duckdb``). Metadata only. """ - name: str - provider_type: t.Any # CONST_DB_PROVIDER_TYPE member - parameters: list[ParameterSpec] - auth_modes: list[type] # list[type[AuthSpec]] — forward-refd to avoid cycle default_port: int | None = None connection_string_scheme: str | None = None ibis_dialect: str | None = None diff --git a/src/mountainash_data/core/settings/duckdb.py b/src/mountainash_data/core/settings/duckdb.py index 9e61d15..2ddb94f 100644 --- a/src/mountainash_data/core/settings/duckdb.py +++ b/src/mountainash_data/core/settings/duckdb.py @@ -14,7 +14,7 @@ from pydantic import field_validator from ..constants import CONST_DB_PROVIDER_TYPE -from .auth import NoAuth +from mountainash_settings.auth import NoAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/motherduck.py b/src/mountainash_data/core/settings/motherduck.py index a22de89..607f401 100644 --- a/src/mountainash_data/core/settings/motherduck.py +++ b/src/mountainash_data/core/settings/motherduck.py @@ -11,7 +11,7 @@ import typing as t from ..constants import CONST_DB_PROVIDER_TYPE -from .auth import TokenAuth +from mountainash_settings.auth import TokenAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/mssql.py b/src/mountainash_data/core/settings/mssql.py index 9b61777..9f3bb48 100644 --- a/src/mountainash_data/core/settings/mssql.py +++ b/src/mountainash_data/core/settings/mssql.py @@ -11,7 +11,7 @@ from ..constants import CONST_DB_PROVIDER_TYPE from .adapters import mssql as _adapter -from .auth import AzureADAuth, PasswordAuth, WindowsAuth +from mountainash_settings.auth import AzureADAuth, PasswordAuth, WindowsAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/mysql.py b/src/mountainash_data/core/settings/mysql.py index 81b7132..b58807f 100644 --- a/src/mountainash_data/core/settings/mysql.py +++ b/src/mountainash_data/core/settings/mysql.py @@ -14,7 +14,7 @@ from ..constants import CONST_DB_PROVIDER_TYPE from .adapters import mysql as _adapter -from .auth import PasswordAuth +from mountainash_settings.auth import PasswordAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/postgresql.py b/src/mountainash_data/core/settings/postgresql.py index b581ac5..36e5433 100644 --- a/src/mountainash_data/core/settings/postgresql.py +++ b/src/mountainash_data/core/settings/postgresql.py @@ -15,7 +15,7 @@ from pydantic import SecretStr from ..constants import CONST_DB_PROVIDER_TYPE -from .auth import NoAuth, PasswordAuth +from mountainash_settings.auth import NoAuth, PasswordAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/profile.py b/src/mountainash_data/core/settings/profile.py index 45cb63f..9fee9b2 100644 --- a/src/mountainash_data/core/settings/profile.py +++ b/src/mountainash_data/core/settings/profile.py @@ -1,158 +1,45 @@ -"""Generic ConnectionProfile base for all backend settings. +"""ConnectionProfile — database-flavored subclass of DescriptorProfile. -A subclass declares ``__descriptor__`` (a :class:`BackendDescriptor`); this -base uses pydantic v2's ``__pydantic_init_subclass__`` hook to materialize the -descriptor into pydantic fields, compose the :class:`AuthSpec` union into the -``auth`` field, and install the generic :meth:`to_driver_kwargs` / -:meth:`to_connection_string` API. +Adds ``to_driver_kwargs()`` and ``to_connection_string()`` on top of the +generic mechanism provided by +:class:`mountainash_settings.profiles.DescriptorProfile`. """ from __future__ import annotations import typing as t - from urllib.parse import quote -from pydantic import AfterValidator, SecretStr -from pydantic.fields import FieldInfo - -from mountainash_settings import MountainAshBaseSettings +from pydantic import SecretStr -from .auth.dispatch import auth_to_driver_kwargs -from .descriptor import MISSING, BackendDescriptor +from mountainash_settings.profiles import DescriptorProfile __all__ = ["ConnectionProfile"] -class ConnectionProfile(MountainAshBaseSettings): - """Declarative settings base — subclasses set ``__descriptor__`` only. +class ConnectionProfile(DescriptorProfile): + """Database connection settings. Public API: - - :attr:`backend` — descriptor name. - - :attr:`provider_type` — descriptor provider_type. - - :meth:`to_driver_kwargs` — dict ready for the underlying driver. - - :meth:`to_connection_string` — URL form (or ``NotImplementedError`` - if the backend has no URL scheme). - """ - - __descriptor__: t.ClassVar[BackendDescriptor] - __adapter__: t.ClassVar[ - t.Callable[["ConnectionProfile"], dict[str, t.Any]] | None - ] = None - - @classmethod - def __pydantic_init_subclass__(cls, **kwargs: t.Any) -> None: - """Install fields described by ``__descriptor__`` on the subclass.""" - super().__pydantic_init_subclass__(**kwargs) - desc = cls.__dict__.get("__descriptor__") - if desc is None: - return # intermediate subclass without its own descriptor - - # Build the field additions - new_fields: dict[str, tuple[t.Any, FieldInfo]] = {} - - # 1. Descriptor parameters → pydantic fields - for spec in desc.parameters: - ptype: t.Any = SecretStr if spec.secret else spec.type - if spec.validator is not None: - ptype = t.Annotated[ptype, AfterValidator(spec.validator)] - if spec.default is MISSING: - info = FieldInfo( - annotation=ptype, - default=..., - description=spec.description, - ) - else: - info = FieldInfo( - annotation=ptype, - default=spec.default, - description=spec.description, - ) - new_fields[spec.name] = (ptype, info) - - # 2. auth field as discriminated union of descriptor.auth_modes - if desc.auth_modes: - # Dynamic Union type from the descriptor's auth_modes list. - auth_union: t.Any - if len(desc.auth_modes) == 1: - auth_union = desc.auth_modes[0] - auth_info = FieldInfo(annotation=auth_union, default=...) - else: - auth_union = t.Union[tuple(desc.auth_modes)] # type: ignore[valid-type] - auth_info = FieldInfo( - annotation=auth_union, - default=..., - discriminator="kind", - ) - new_fields["auth"] = (auth_union, auth_info) - - # Install fields and rebuild the model - for name, (annotation, info) in new_fields.items(): - cls.model_fields[name] = info - cls.__annotations__[name] = annotation - - cls.model_rebuild(force=True) - - # --- Public properties --------------------------------------------------- + - :meth:`to_driver_kwargs` — dict ready for the Ibis driver. + - :meth:`to_connection_string` — URL form, or ``NotImplementedError`` + if the descriptor has no ``connection_string_scheme`` metadata. - @property - def backend(self) -> str: - return self.__descriptor__.name - - @property - def provider_type(self) -> t.Any: - return self.__descriptor__.provider_type - - # --- Driver kwargs -------------------------------------------------------- - - def _default_driver_kwargs(self) -> dict[str, t.Any]: - """Emit 1:1 driver_key mappings from the descriptor. - - - Skips ``None`` values. - - Unwraps :class:`SecretStr` via ``.get_secret_value()``. - - Applies ``ParameterSpec.transform`` if set. - """ - out: dict[str, t.Any] = {} - for spec in self.__descriptor__.parameters: - if spec.driver_key is None: - continue - val = getattr(self, spec.name, None) - if val is None: - continue - # The isinstance guard accommodates both construction paths: - # (a) pydantic's normal validation coerces a string default - # into SecretStr; we unwrap here. (b) MountainAshBaseSettings' - # ``update_settings_from_dict`` uses ``setattr`` directly and - # bypasses pydantic coercion — a raw ``str`` arrives and - # passes through unchanged. - if isinstance(val, SecretStr): - val = val.get_secret_value() - if spec.transform is not None: - val = spec.transform(val) - out[spec.driver_key] = val - return out - - def _auth_to_driver_kwargs(self) -> dict[str, t.Any]: - auth = getattr(self, "auth", None) - if auth is None: - return {} - return auth_to_driver_kwargs(auth) + Subclasses set ``__descriptor__`` (a :class:`ProfileDescriptor`) and + optionally ``__adapter__``. Field installation, auth union, and template + wiring are inherited from :class:`DescriptorProfile`. + """ def to_driver_kwargs(self) -> dict[str, t.Any]: """Build the final driver kwargs dict. - If ``__adapter__`` is set, it is responsible for the full pipeline — - it typically calls :meth:`_default_driver_kwargs` and - :meth:`_auth_to_driver_kwargs` itself, then layers any composite - mappings (nested dicts, wrapper objects, driver-specific auth - adapters). Its return value is used verbatim. - - Otherwise the default is: 1:1 parameter mappings from the descriptor, - then auth dispatch overlaid on top. + If ``__adapter__`` is set, it owns the full pipeline — typically it + calls :meth:`_default_kwargs` and :meth:`_auth_kwargs` and layers + composite mappings on top. Otherwise defaults to descriptor + ``driver_key`` mappings + default auth dispatch. """ adapter = type(self).__dict__.get("__adapter__") if adapter is None: - # Walk MRO in case adapter is defined on a parent shell class for base in type(self).__mro__[1:]: candidate = base.__dict__.get("__adapter__") if candidate is not None: @@ -160,22 +47,24 @@ def to_driver_kwargs(self) -> dict[str, t.Any]: break if adapter is not None: return adapter(self) - kwargs = self._default_driver_kwargs() - kwargs.update(self._auth_to_driver_kwargs()) + kwargs = self._default_kwargs() + kwargs.update(self._auth_kwargs()) return kwargs - # --- Connection string ---------------------------------------------------- - def to_connection_string(self) -> str: - """Build ``scheme://...`` form from the descriptor. + """Build ``scheme://user:pass@host:port/database`` from the descriptor. - Raises :class:`NotImplementedError` if the descriptor has no scheme. - Backends with non-standard URL shapes override this method. + Reads the scheme from ``descriptor.metadata['connection_string_scheme']`` + (or a typed ``connection_string_scheme`` attribute if the descriptor + subclass provides one). Raises :class:`NotImplementedError` if absent. """ - scheme = self.__descriptor__.connection_string_scheme + desc = self.__descriptor__ + scheme = getattr(desc, "connection_string_scheme", None) + if scheme is None: + scheme = desc.metadata.get("connection_string_scheme") if scheme is None: raise NotImplementedError( - f"Backend {self.backend!r} has no connection string scheme" + f"Profile {self.backend!r} has no connection string scheme" ) host = getattr(self, "HOST", None) port = getattr(self, "PORT", None) diff --git a/src/mountainash_data/core/settings/pyiceberg_rest.py b/src/mountainash_data/core/settings/pyiceberg_rest.py index 52ee93a..6239bab 100644 --- a/src/mountainash_data/core/settings/pyiceberg_rest.py +++ b/src/mountainash_data/core/settings/pyiceberg_rest.py @@ -12,7 +12,7 @@ from ..constants import CONST_DB_PROVIDER_TYPE from .adapters import pyiceberg_rest as _adapter -from .auth import OAuth2Auth, TokenAuth +from mountainash_settings.auth import OAuth2Auth, TokenAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/pyspark.py b/src/mountainash_data/core/settings/pyspark.py index 466bc3c..2c42a60 100644 --- a/src/mountainash_data/core/settings/pyspark.py +++ b/src/mountainash_data/core/settings/pyspark.py @@ -15,7 +15,7 @@ from ..constants import CONST_DB_PROVIDER_TYPE from .adapters import pyspark as _adapter -from .auth import NoAuth +from mountainash_settings.auth import NoAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/redshift.py b/src/mountainash_data/core/settings/redshift.py index 3a9c31f..34ad712 100644 --- a/src/mountainash_data/core/settings/redshift.py +++ b/src/mountainash_data/core/settings/redshift.py @@ -15,7 +15,7 @@ from ..constants import CONST_DB_PROVIDER_TYPE from .adapters import redshift as _adapter -from .auth import IAMAuth, PasswordAuth +from mountainash_settings.auth import IAMAuth, PasswordAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/registry.py b/src/mountainash_data/core/settings/registry.py index fd30d45..54e6f4f 100644 --- a/src/mountainash_data/core/settings/registry.py +++ b/src/mountainash_data/core/settings/registry.py @@ -1,105 +1,82 @@ -"""Module-level registry of backend descriptors and settings classes. +"""Module-level registry of database backend descriptors. -Registration happens at import time only; runtime re-registration is -unsupported. Mutating ``REGISTRY`` directly bypasses the duplicate-name -check — always use :func:`register`. +Backed by :class:`mountainash_settings.profiles.Registry` — a per-domain +registry class. The old module-level ``REGISTRY`` dict is preserved as a +property-style alias for any downstream consumer that imports it directly. """ from __future__ import annotations import typing as t +from collections.abc import Mapping -from .descriptor import BackendDescriptor -from .profile import ConnectionProfile - -__all__ = ["REGISTRY", "register", "get_descriptor", "get_settings_class"] - -REGISTRY: dict[str, BackendDescriptor] = {} -_CLASSES: dict[str, type[ConnectionProfile]] = {} - - -T = t.TypeVar("T", bound=ConnectionProfile) - - -def register( - descriptor: BackendDescriptor, -) -> t.Callable[[type[T]], type[T]]: - """Class decorator that registers a :class:`ConnectionProfile` subclass. - - Raises: - ValueError: if ``descriptor.name`` is already registered. - - Note: - Subclasses must still declare ``__descriptor__ = desc`` in the class - body for pydantic field materialization — ``ConnectionProfile``'s - ``__pydantic_init_subclass__`` hook reads ``__descriptor__`` at class - creation time, before this decorator runs. The decorator's - ``cls.__descriptor__`` assignment is a post-hoc safety net only. - """ - if descriptor.name in REGISTRY: - existing = _CLASSES.get(descriptor.name) - where = ( - f"{existing.__module__}.{existing.__qualname__}" - if existing is not None - else "" - ) - raise ValueError( - f"Backend {descriptor.name!r} is already registered by {where}" - ) - - def _wrap(cls: type[T]) -> type[T]: - REGISTRY[descriptor.name] = descriptor - _CLASSES[descriptor.name] = cls - cls.__descriptor__ = descriptor # optional: class body usually sets this; this line is a no-op safety net - return cls - - return _wrap - - -def get_descriptor(name: str) -> BackendDescriptor: - """Return the :class:`BackendDescriptor` for ``name``. - - Raises: - KeyError: if ``name`` is not registered. - """ - try: - return REGISTRY[name] - except KeyError: - known = ", ".join(sorted(REGISTRY)) or "" - raise KeyError( - f"No backend registered under {name!r}. Known: {known}" - ) from None - - -def get_settings_class(name: str) -> type[ConnectionProfile]: - """Return the registered settings class for ``name``. - - Raises: - KeyError: if ``name`` is not registered. - """ - try: - return _CLASSES[name] - except KeyError: - known = ", ".join(sorted(_CLASSES)) or "" - raise KeyError( - f"No settings class registered under {name!r}. Known: {known}" - ) from None - - -def _reset_for_tests( - registry_snapshot: dict[str, BackendDescriptor], - classes_snapshot: dict[str, type[ConnectionProfile]], -) -> None: - """Restore REGISTRY and _CLASSES to snapshots (test-only helper).""" - REGISTRY.clear() - REGISTRY.update(registry_snapshot) - _CLASSES.clear() - _CLASSES.update(classes_snapshot) - - -def _snapshot_for_tests() -> tuple[ - dict[str, BackendDescriptor], - dict[str, type[ConnectionProfile]], -]: - """Return a copy of REGISTRY and _CLASSES for later restore.""" - return REGISTRY.copy(), _CLASSES.copy() +from mountainash_settings.profiles import Registry + +if t.TYPE_CHECKING: + from mountainash_settings.profiles import ProfileDescriptor + from .profile import ConnectionProfile + +__all__ = [ + "DATABASES_REGISTRY", + "REGISTRY", + "_reset_for_tests", + "_snapshot_for_tests", + "get_descriptor", + "get_settings_class", + "register", +] + +DATABASES_REGISTRY = Registry("databases") + +register = DATABASES_REGISTRY.decorator() + + +def get_descriptor(name: str) -> "ProfileDescriptor": + return DATABASES_REGISTRY.get_descriptor(name) + + +def get_settings_class(name: str) -> type["ConnectionProfile"]: + return DATABASES_REGISTRY.get_settings_class(name) # type: ignore[return-value] + + +# Backwards-compatibility alias — preserves ``from ... import REGISTRY`` imports. +# Read-only from the outside; mutations should go through ``@register``. +class _RegistryDictView(Mapping): + """Dict-like view that delegates to DATABASES_REGISTRY.descriptors.""" + + def __contains__(self, name: object) -> bool: + return isinstance(name, str) and name in DATABASES_REGISTRY + + def __getitem__(self, name: str) -> "ProfileDescriptor": + return DATABASES_REGISTRY.get_descriptor(name) + + def __iter__(self) -> t.Iterator[str]: + return iter(DATABASES_REGISTRY.descriptors) + + def __len__(self) -> int: + return len(DATABASES_REGISTRY) + + def items(self) -> t.ItemsView[str, "ProfileDescriptor"]: + return DATABASES_REGISTRY.descriptors.items() + + def keys(self) -> t.KeysView[str]: + return DATABASES_REGISTRY.descriptors.keys() + + def values(self) -> t.ValuesView["ProfileDescriptor"]: + return DATABASES_REGISTRY.descriptors.values() + + +REGISTRY = _RegistryDictView() + + +# Test seams — thin wrappers around the Registry instance methods so test +# modules can import them as module-level names. + +def _snapshot_for_tests() -> tuple[dict, dict]: + """Return a snapshot of the registry state for test isolation.""" + return DATABASES_REGISTRY._snapshot_for_tests() + + +def _reset_for_tests(descriptors_snapshot: dict, classes_snapshot: dict) -> None: + """Restore registry state from a prior snapshot (test-only).""" + DATABASES_REGISTRY._reset_for_tests(descriptors_snapshot, classes_snapshot) diff --git a/src/mountainash_data/core/settings/snowflake.py b/src/mountainash_data/core/settings/snowflake.py index b5b5443..929d4c8 100644 --- a/src/mountainash_data/core/settings/snowflake.py +++ b/src/mountainash_data/core/settings/snowflake.py @@ -11,7 +11,7 @@ from ..constants import CONST_DB_PROVIDER_TYPE from .adapters import snowflake as _adapter -from .auth import CertificateAuth, OAuth2Auth, PasswordAuth, TokenAuth +from mountainash_settings.auth import CertificateAuth, OAuth2Auth, PasswordAuth, TokenAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/sqlite.py b/src/mountainash_data/core/settings/sqlite.py index 6862685..5bf872c 100644 --- a/src/mountainash_data/core/settings/sqlite.py +++ b/src/mountainash_data/core/settings/sqlite.py @@ -10,7 +10,7 @@ import typing as t from ..constants import CONST_DB_PROVIDER_TYPE -from .auth import NoAuth +from mountainash_settings.auth import NoAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/src/mountainash_data/core/settings/trino.py b/src/mountainash_data/core/settings/trino.py index 7d7bab3..72e656a 100644 --- a/src/mountainash_data/core/settings/trino.py +++ b/src/mountainash_data/core/settings/trino.py @@ -12,7 +12,7 @@ from ..constants import CONST_DB_PROVIDER_TYPE from .adapters import trino as _adapter -from .auth import JWTAuth, KerberosAuth, NoAuth, PasswordAuth +from mountainash_settings.auth import JWTAuth, KerberosAuth, NoAuth, PasswordAuth from .descriptor import BackendDescriptor, ParameterSpec from .profile import ConnectionProfile from .registry import register diff --git a/tests/test_unit/core/settings/test_auth.py b/tests/test_unit/core/settings/test_auth.py deleted file mode 100644 index 6c6f25a..0000000 --- a/tests/test_unit/core/settings/test_auth.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Unit tests for AuthSpec discriminated-union members.""" - -import pytest -from pydantic import SecretStr, ValidationError - -from mountainash_data.core.settings.auth import ( - AzureADAuth, - CertificateAuth, - IAMAuth, - JWTAuth, - KerberosAuth, - NoAuth, - OAuth2Auth, - PasswordAuth, - ServiceAccountAuth, - TokenAuth, - WindowsAuth, -) - - -@pytest.mark.unit -class TestAuthDiscriminator: - @pytest.mark.parametrize( - "cls, kind", - [ - (NoAuth, "none"), - (PasswordAuth, "password"), - (TokenAuth, "token"), - (JWTAuth, "jwt"), - (OAuth2Auth, "oauth2"), - (ServiceAccountAuth, "service_account"), - (IAMAuth, "iam"), - (WindowsAuth, "windows"), - (AzureADAuth, "azure_ad"), - (KerberosAuth, "kerberos"), - (CertificateAuth, "certificate"), - ], - ) - def test_every_auth_has_discriminator_kind(self, cls, kind): - if cls is PasswordAuth: - instance = cls(username="u", password=SecretStr("p")) - elif cls in (TokenAuth, JWTAuth): - instance = cls(token=SecretStr("t")) - else: - instance = cls() - assert instance.kind == kind - - def test_password_auth_requires_username_and_password(self): - with pytest.raises(ValidationError): - PasswordAuth() # type: ignore[call-arg] - - def test_password_auth_wraps_password_as_secretstr(self): - auth = PasswordAuth(username="alice", password="hunter2") - assert isinstance(auth.password, SecretStr) - assert auth.password.get_secret_value() == "hunter2" - - def test_noauth_has_no_fields(self): - auth = NoAuth() - assert auth.kind == "none" - - def test_auth_is_frozen(self): - """Mutation of an AuthSpec instance must raise.""" - auth = NoAuth() - with pytest.raises(ValidationError): - auth.kind = "password" # type: ignore[misc] - - def test_auth_rejects_unknown_fields(self): - """Unknown kwargs must raise because model_config.extra == 'forbid'.""" - with pytest.raises(ValidationError): - NoAuth(bogus="x") # type: ignore[call-arg] - - -@pytest.mark.unit -class TestOAuth2Auth: - def test_all_fields_optional(self): - auth = OAuth2Auth() - assert auth.client_id is None - assert auth.client_secret is None - assert auth.token is None - - def test_token_is_secret(self): - auth = OAuth2Auth(token="t") - assert isinstance(auth.token, SecretStr) diff --git a/tests/test_unit/core/settings/test_auth_dispatch.py b/tests/test_unit/core/settings/test_auth_dispatch.py deleted file mode 100644 index eedb141..0000000 --- a/tests/test_unit/core/settings/test_auth_dispatch.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Default AUTH_TO_DRIVER_KWARGS coverage tests.""" - -import pytest -from pydantic import SecretStr - -from mountainash_data.core.settings.auth import ( - AuthSpec, - IAMAuth, - JWTAuth, - NoAuth, - OAuth2Auth, - PasswordAuth, - TokenAuth, -) -from mountainash_data.core.settings.auth.dispatch import auth_to_driver_kwargs - - -@pytest.mark.unit -class TestAuthToDriverKwargs: - def test_noauth_returns_empty(self): - assert auth_to_driver_kwargs(NoAuth()) == {} - - def test_password_unwraps_secret(self): - auth = PasswordAuth(username="alice", password=SecretStr("hunter2")) - assert auth_to_driver_kwargs(auth) == { - "user": "alice", - "password": "hunter2", - } - - def test_token_unwraps_secret(self): - auth = TokenAuth(token=SecretStr("t")) - assert auth_to_driver_kwargs(auth) == {"token": "t"} - - def test_jwt_unwraps_secret(self): - auth = JWTAuth(token=SecretStr("j")) - assert auth_to_driver_kwargs(auth) == {"token": "j"} - - def test_oauth2_with_token(self): - auth = OAuth2Auth(token=SecretStr("bearer")) - assert auth_to_driver_kwargs(auth) == {"token": "bearer"} - - def test_oauth2_with_client_credentials(self): - auth = OAuth2Auth( - client_id="cid", client_secret=SecretStr("csec") - ) - assert auth_to_driver_kwargs(auth) == {"credential": "cid:csec"} - - def test_oauth2_token_wins_over_client_credentials(self): - """Policy: if both token and client_credentials are set, token wins.""" - auth = OAuth2Auth( - token=SecretStr("t"), - client_id="c", - client_secret=SecretStr("s"), - ) - assert auth_to_driver_kwargs(auth) == {"token": "t"} - - def test_oauth2_empty_returns_empty(self): - """OAuth2 with neither token nor client-credentials yields no kwargs.""" - assert auth_to_driver_kwargs(OAuth2Auth()) == {} - - def test_iam_with_keys(self): - auth = IAMAuth( - access_key_id="AKIA...", - secret_access_key=SecretStr("sk"), - session_token=SecretStr("st"), - ) - assert auth_to_driver_kwargs(auth) == { - "aws_access_key_id": "AKIA...", - "aws_secret_access_key": "sk", - "aws_session_token": "st", - } - - def test_iam_with_role_arn(self): - auth = IAMAuth(role_arn="arn:aws:iam::123:role/x") - assert auth_to_driver_kwargs(auth) == { - "iam_role_arn": "arn:aws:iam::123:role/x" - } - - def test_iam_empty_returns_empty(self): - """IAM with no explicit fields falls through to ambient credentials.""" - assert auth_to_driver_kwargs(IAMAuth()) == {} - - def test_unknown_auth_type_raises(self): - class WeirdAuth(AuthSpec): - kind: str = "weird" # type: ignore[assignment] - - with pytest.raises(KeyError): - auth_to_driver_kwargs(WeirdAuth()) diff --git a/tests/test_unit/core/settings/test_descriptor.py b/tests/test_unit/core/settings/test_descriptor.py index c45d47d..ded8375 100644 --- a/tests/test_unit/core/settings/test_descriptor.py +++ b/tests/test_unit/core/settings/test_descriptor.py @@ -1,70 +1,44 @@ -"""Unit tests for settings descriptor primitives.""" +"""Tests for the database-flavored BackendDescriptor subclass.""" import pytest -from dataclasses import FrozenInstanceError +from mountainash_data.core.settings.auth import NoAuth from mountainash_data.core.settings.descriptor import ( BackendDescriptor, - MISSING, ParameterSpec, ) @pytest.mark.unit -class TestParameterSpec: - def test_minimal_parameter_spec(self): - spec = ParameterSpec(name="FOO", type=str, tier="core") - assert spec.name == "FOO" - assert spec.type is str - assert spec.tier == "core" - assert spec.default is MISSING - assert spec.driver_key is None - assert spec.secret is False - assert spec.transform is None - assert spec.validator is None - - def test_parameter_spec_is_frozen(self): - spec = ParameterSpec(name="FOO", type=str, tier="core") - with pytest.raises(FrozenInstanceError): - spec.name = "BAR" # type: ignore[misc] - - def test_parameter_spec_with_default(self): - spec = ParameterSpec(name="PORT", type=int, tier="core", default=5432) - assert spec.default == 5432 - - def test_parameter_spec_secret_flag(self): - spec = ParameterSpec(name="PASSWORD", type=str, tier="core", secret=True) - assert spec.secret is True - - def test_parameter_spec_accepts_advanced_tier(self): - spec = ParameterSpec(name="X", type=str, tier="advanced") - assert spec.tier == "advanced" - - def test_missing_sentinel_is_falsy_and_singleton(self): - from mountainash_data.core.settings.descriptor import _Missing - assert bool(MISSING) is False - assert repr(MISSING) == "MISSING" - assert _Missing() is MISSING +class TestBackendDescriptor: + def test_default_port_field(self): + d = BackendDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], + default_port=5432, + ) + assert d.default_port == 5432 + def test_connection_string_scheme_field(self): + d = BackendDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], + connection_string_scheme="postgresql://", + ) + assert d.connection_string_scheme == "postgresql://" -@pytest.mark.unit -class TestBackendDescriptor: - def test_minimal_descriptor(self): - desc = BackendDescriptor( - name="sqlite", - provider_type="sqlite", - parameters=[], - auth_modes=[], + def test_rides_on_field(self): + d = BackendDescriptor( + name="motherduck", provider_type="motherduck", + parameters=[], auth_modes=[NoAuth], + rides_on="duckdb", ) - assert desc.name == "sqlite" - assert desc.default_port is None - assert desc.connection_string_scheme is None - assert desc.ibis_dialect is None - assert desc.rides_on is None + assert d.rides_on == "duckdb" - def test_descriptor_is_frozen(self): - desc = BackendDescriptor( - name="sqlite", provider_type="sqlite", parameters=[], auth_modes=[] + def test_frozen(self): + d = BackendDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], ) - with pytest.raises(FrozenInstanceError): - desc.name = "mysql" # type: ignore[misc] + with pytest.raises(Exception): + d.name = "y" # type: ignore diff --git a/tests/test_unit/core/settings/test_descriptors_invariants.py b/tests/test_unit/core/settings/test_descriptors_invariants.py index 7e3bb3b..8868b48 100644 --- a/tests/test_unit/core/settings/test_descriptors_invariants.py +++ b/tests/test_unit/core/settings/test_descriptors_invariants.py @@ -1,82 +1,18 @@ -# tests/test_unit/core/settings/test_descriptors_invariants.py -"""Parametric invariants every registered backend must satisfy. +"""Parametric descriptor invariants for all registered database backends. -Runs once per :data:`REGISTRY` entry. New backends get coverage for free. +Generated from the shared ``descriptor_invariants_for`` helper in +``mountainash-settings``. Every descriptor in ``DATABASES_REGISTRY`` gets +checked against the invariants for free — no per-backend test additions +required. """ from __future__ import annotations -import pytest - -# Ensure every backend module that calls @register is imported before we -# snapshot REGISTRY for the parametrize decorator. Today this is a no-op -# (no backends registered yet); Task 19 wires __init__.py re-exports that -# trigger @register at import time. +# Ensure every backend module's @register decorator has fired before we +# snapshot the registry for the parametrize decorator. import mountainash_data.core.settings # noqa: F401 -from mountainash_data.core.settings.auth.base import AuthSpec -from mountainash_data.core.settings.registry import REGISTRY - - -@pytest.mark.unit -@pytest.mark.parametrize( - "name,descriptor", - list(REGISTRY.items()), - ids=list(REGISTRY.keys()) or [""], -) -class TestDescriptorInvariants: - def test_name_matches_registry_key(self, name, descriptor): - assert descriptor.name == name - - def test_parameter_names_unique(self, name, descriptor): - names = [p.name for p in descriptor.parameters] - assert len(names) == len(set(names)), f"duplicate param in {name}" - - def test_driver_keys_unique(self, name, descriptor): - keys = [p.driver_key for p in descriptor.parameters if p.driver_key] - assert len(keys) == len(set(keys)), f"duplicate driver_key in {name}" - - def test_parameter_tiers_valid(self, name, descriptor): - for p in descriptor.parameters: - assert p.tier in {"core", "advanced"}, ( - f"{name}.{p.name} has invalid tier {p.tier!r}" - ) - - def test_auth_modes_are_authspec_subclasses(self, name, descriptor): - for mode in descriptor.auth_modes: - assert issubclass(mode, AuthSpec), ( - f"{name}.auth_modes contains non-AuthSpec: {mode}" - ) - - def test_provider_type_is_not_none(self, name, descriptor): - assert descriptor.provider_type is not None, ( - f"{name} has no provider_type" - ) - - def test_name_is_lowercase_nonempty(self, name, descriptor): - assert descriptor.name, f"{name}: BackendDescriptor.name is empty" - assert descriptor.name == descriptor.name.lower(), ( - f"{name}: BackendDescriptor.name must be lowercase" - ) - - def test_auth_modes_nonempty(self, name, descriptor): - assert descriptor.auth_modes, ( - f"{name}: auth_modes is empty — use [NoAuth] for no-auth backends" - ) - - def test_parameter_names_are_uppercase(self, name, descriptor): - for p in descriptor.parameters: - assert p.name == p.name.upper(), ( - f"{name}.{p.name}: ParameterSpec.name must be UPPERCASE" - ) - assert p.name, f"{name}: ParameterSpec.name is empty" +from mountainash_data.core.settings.registry import DATABASES_REGISTRY +from mountainash_settings.profiles import descriptor_invariants_for - def test_default_port_in_valid_range(self, name, descriptor): - if descriptor.default_port is None: - return - assert isinstance(descriptor.default_port, int), ( - f"{name}: default_port must be int, got {type(descriptor.default_port)}" - ) - assert 1 <= descriptor.default_port <= 65535, ( - f"{name}: default_port {descriptor.default_port} out of TCP range" - ) +TestDatabaseInvariants = descriptor_invariants_for(DATABASES_REGISTRY) diff --git a/tests/test_unit/core/settings/test_profile.py b/tests/test_unit/core/settings/test_profile.py index f71a7ce..f5d9f0b 100644 --- a/tests/test_unit/core/settings/test_profile.py +++ b/tests/test_unit/core/settings/test_profile.py @@ -1,9 +1,14 @@ -"""Unit tests for the generic ConnectionProfile base.""" +"""Tests for ConnectionProfile — database-flavored DescriptorProfile. + +DescriptorProfile mechanism tests live in mountainash-settings. Here we only +exercise the database-specific methods: to_driver_kwargs() and +to_connection_string(). +""" from __future__ import annotations import pytest -from pydantic import SecretStr, ValidationError +from pydantic import SecretStr from mountainash_data.core.settings.auth import NoAuth, PasswordAuth from mountainash_data.core.settings.descriptor import ( @@ -21,8 +26,8 @@ parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=9999, driver_key="port"), - ParameterSpec(name="PASSWORD", type=str, tier="core", secret=True, - driver_key="password", default=None), + ParameterSpec(name="DATABASE", type=str, tier="core", default=None, + driver_key="database"), ], auth_modes=[NoAuth, PasswordAuth], ) @@ -34,184 +39,59 @@ class DummyProfile(ConnectionProfile): @pytest.mark.unit class TestConnectionProfile: - def test_required_field_enforced(self): - with pytest.raises(ValidationError): - DummyProfile(auth=NoAuth()) # HOST missing - - def test_default_used_when_not_provided(self): - p = DummyProfile(HOST="localhost", auth=NoAuth()) - assert p.PORT == 9999 - - def test_to_driver_kwargs_noauth(self): - p = DummyProfile(HOST="h", PORT=1234, auth=NoAuth()) - assert p.to_driver_kwargs() == {"host": "h", "port": 1234} + def test_to_driver_kwargs_default(self): + p = DummyProfile(HOST="h", PORT=1234, DATABASE="db", auth=NoAuth()) + kwargs = p.to_driver_kwargs() + assert kwargs["host"] == "h" + assert kwargs["port"] == 1234 + assert kwargs["database"] == "db" - def test_to_driver_kwargs_password_auth_unwraps_secret(self): + def test_to_driver_kwargs_password_unwrapped(self): p = DummyProfile( - HOST="h", + HOST="h", DATABASE="db", auth=PasswordAuth(username="u", password=SecretStr("p")), ) kwargs = p.to_driver_kwargs() - assert kwargs["host"] == "h" assert kwargs["user"] == "u" assert kwargs["password"] == "p" - def test_secret_field_unwrapped_in_driver_kwargs(self): - p = DummyProfile(HOST="h", PASSWORD="literal-secret", auth=NoAuth()) - kwargs = p.to_driver_kwargs() - assert kwargs["password"] == "literal-secret" - - def test_none_values_skipped_from_driver_kwargs(self): - p = DummyProfile(HOST="h", auth=NoAuth()) - kwargs = p.to_driver_kwargs() - assert "password" not in kwargs # PASSWORD default is None - - def test_provider_type_property(self): - p = DummyProfile(HOST="h", auth=NoAuth()) - assert p.provider_type == "dummy" - - def test_backend_property(self): - p = DummyProfile(HOST="h", auth=NoAuth()) - assert p.backend == "dummy" - - def test_to_connection_string_raises_when_scheme_none(self): - desc = BackendDescriptor( - name="x", provider_type="x", parameters=[], auth_modes=[NoAuth], - connection_string_scheme=None, - ) - - class P(ConnectionProfile): - __descriptor__ = desc + def test_to_driver_kwargs_adapter_owns_pipeline(self): + def _adapter(profile): + return {"only": "thing"} - p = P(auth=NoAuth()) - with pytest.raises(NotImplementedError): - p.to_connection_string() - - # --- Item 4: adapter replaces pipeline output -------------------------------- - - def test_adapter_replaces_pipeline_output(self): - """When __adapter__ is set, it owns the full kwargs pipeline.""" - def _adapter(profile: "ConnectionProfile") -> dict: - # Adapter can still call the default helpers if it wants - kwargs = profile._default_driver_kwargs() - kwargs["adapter_added"] = True - return kwargs - - class AdaptedProfile(ConnectionProfile): + class Adapted(ConnectionProfile): __descriptor__ = DUMMY_DESCRIPTOR __adapter__ = staticmethod(_adapter) - p = AdaptedProfile(HOST="h", auth=NoAuth()) - kwargs = p.to_driver_kwargs() - assert kwargs["host"] == "h" - assert kwargs["adapter_added"] is True - - def test_adapter_can_return_fresh_dict(self): - """Adapter return value is used verbatim; it need not extend defaults.""" - class FreshProfile(ConnectionProfile): - __descriptor__ = DUMMY_DESCRIPTOR - __adapter__ = staticmethod(lambda self: {"only_key": "only_val"}) - - p = FreshProfile(HOST="h", auth=NoAuth()) - assert p.to_driver_kwargs() == {"only_key": "only_val"} + p = Adapted(HOST="h", auth=NoAuth()) + assert p.to_driver_kwargs() == {"only": "thing"} - # --- Item 5: ParameterSpec.transform is applied ------------------------------ - - def test_parameter_spec_transform_is_applied(self): - """transform= is applied at the kwargs boundary.""" - desc = BackendDescriptor( - name="tf", - provider_type="tf", - auth_modes=[NoAuth], - parameters=[ - ParameterSpec( - name="FLAG", type=bool, tier="core", - default=True, driver_key="flag", - transform=lambda v: 1 if v else 0, - ), - ], + def test_to_connection_string_full(self): + p = DummyProfile( + HOST="h", DATABASE="db", + auth=PasswordAuth(username="u", password=SecretStr("p")), ) + url = p.to_connection_string() + assert url == "dummy://u:p@h:9999/db" - class P(ConnectionProfile): - __descriptor__ = desc - - p = P(auth=NoAuth()) - assert p.to_driver_kwargs() == {"flag": 1} - - p2 = P(FLAG=False, auth=NoAuth()) - assert p2.to_driver_kwargs() == {"flag": 0} - - # --- ParameterSpec.validator wired via AfterValidator ------------------------- - - def test_parameter_spec_validator_rejects_bad_input(self): - """validator= on ParameterSpec is wired as a pydantic AfterValidator.""" - def _must_be_positive(v: int) -> int: - if v <= 0: - raise ValueError("must be positive") - return v - - desc = BackendDescriptor( - name="val", - provider_type="val", - auth_modes=[NoAuth], - parameters=[ - ParameterSpec( - name="COUNT", type=int, tier="core", - validator=_must_be_positive, - ), - ], + def test_to_connection_string_url_encodes_secrets(self): + p = DummyProfile( + HOST="h", DATABASE="db", + auth=PasswordAuth(username="user@corp", password=SecretStr("p@ss:w/ord")), ) + url = p.to_connection_string() + assert "user%40corp" in url + assert "p%40ss%3Aw%2Ford" in url - class P(ConnectionProfile): - __descriptor__ = desc - - # Valid value passes - p = P(COUNT=5, auth=NoAuth()) - assert p.COUNT == 5 - - # Invalid value rejected at construction - with pytest.raises(ValidationError, match="must be positive"): - P(COUNT=-1, auth=NoAuth()) - - def test_parameter_spec_validator_allows_valid_input(self): - """validator= passes valid values through unchanged.""" - def _must_be_positive(v: int) -> int: - if v <= 0: - raise ValueError("must be positive") - return v - + def test_to_connection_string_no_scheme_raises(self): desc = BackendDescriptor( - name="val2", - provider_type="val2", - auth_modes=[NoAuth], - parameters=[ - ParameterSpec( - name="N", type=int, tier="core", - default=10, validator=_must_be_positive, - ), - ], + name="x", provider_type="x", parameters=[], auth_modes=[NoAuth], + connection_string_scheme=None, ) class P(ConnectionProfile): __descriptor__ = desc - # Default (10) passes validator p = P(auth=NoAuth()) - assert p.N == 10 - - # Explicit valid value passes - p2 = P(N=42, auth=NoAuth()) - assert p2.N == 42 - - # --- Item 6: URL-encoded password in to_connection_string -------------------- - - def test_to_connection_string_url_encodes_password(self): - """Password special chars must be URL-encoded, not passed raw.""" - p = DummyProfile( - HOST="h", - auth=PasswordAuth(username="user@corp", password=SecretStr("p@ss:w/ord")), - ) - url = p.to_connection_string() - # '@' in username → %40; ':', '@', '/' in password → %3A, %40, %2F - assert "user%40corp" in url - assert "p%40ss%3Aw%2Ford" in url + with pytest.raises(NotImplementedError): + p.to_connection_string() diff --git a/tests/test_unit/core/settings/test_registry.py b/tests/test_unit/core/settings/test_registry.py index 67859ed..2c4d3cf 100644 --- a/tests/test_unit/core/settings/test_registry.py +++ b/tests/test_unit/core/settings/test_registry.py @@ -1,104 +1,40 @@ -"""Unit tests for the backend registry.""" +"""Tests for the DATABASES_REGISTRY wrapper + back-compat REGISTRY alias.""" import pytest -from mountainash_data.core.settings.auth import NoAuth -from mountainash_data.core.settings.descriptor import BackendDescriptor -from mountainash_data.core.settings.profile import ConnectionProfile from mountainash_data.core.settings.registry import ( + DATABASES_REGISTRY, REGISTRY, - _reset_for_tests, - _snapshot_for_tests, get_descriptor, get_settings_class, - register, ) @pytest.mark.unit -class TestRegistry: - def setup_method(self): - self._snapshot = _snapshot_for_tests() - - def teardown_method(self): - _reset_for_tests(*self._snapshot) - - def test_register_inserts_into_registry(self): - desc = BackendDescriptor( - name="my_backend", provider_type="my_backend", - parameters=[], auth_modes=[NoAuth], - ) - - @register(desc) - class MyProfile(ConnectionProfile): - __descriptor__ = desc - - assert REGISTRY["my_backend"] is desc - assert MyProfile.__descriptor__ is desc - - def test_get_descriptor_returns_registered(self): - desc = BackendDescriptor( - name="x", provider_type="x", - parameters=[], auth_modes=[NoAuth], - ) - - @register(desc) - class X(ConnectionProfile): - __descriptor__ = desc - - assert get_descriptor("x") is desc - - def test_get_descriptor_unknown_raises(self): - with pytest.raises(KeyError): - get_descriptor("not_a_real_backend") - - def test_get_settings_class_returns_class(self): - desc = BackendDescriptor( - name="y", provider_type="y", - parameters=[], auth_modes=[NoAuth], - ) - - @register(desc) - class YProfile(ConnectionProfile): - __descriptor__ = desc - - assert get_settings_class("y") is YProfile - - def test_register_rejects_duplicate_name(self): - desc1 = BackendDescriptor(name="dup", provider_type="dup", - parameters=[], auth_modes=[NoAuth]) - desc2 = BackendDescriptor(name="dup", provider_type="dup", - parameters=[], auth_modes=[NoAuth]) - - @register(desc1) - class P1(ConnectionProfile): - __descriptor__ = desc1 - - with pytest.raises(ValueError, match="already registered"): - @register(desc2) - class P2(ConnectionProfile): - __descriptor__ = desc2 - - def test_get_settings_class_unknown_raises(self): - with pytest.raises(KeyError): - get_settings_class("not_a_real_backend") - - def test_register_duplicate_does_not_pollute_classes_dict(self): - """REGISTRY and _CLASSES stay in sync after a rejected duplicate.""" - desc1 = BackendDescriptor(name="inv", provider_type="inv", - parameters=[], auth_modes=[NoAuth]) - desc2 = BackendDescriptor(name="inv", provider_type="inv", - parameters=[], auth_modes=[NoAuth]) - - @register(desc1) - class First(ConnectionProfile): - __descriptor__ = desc1 - - with pytest.raises(ValueError): - @register(desc2) - class Second(ConnectionProfile): - __descriptor__ = desc2 - - # Both dicts still map 'inv' to First — no leak of Second - assert get_settings_class("inv") is First - assert get_descriptor("inv") is desc1 +class TestDatabasesRegistry: + def test_registry_is_populated_after_import(self): + """All 12 backends register themselves at import time.""" + import mountainash_data.core.settings # noqa: F401 + + for name in ["sqlite", "duckdb", "postgresql", "mysql", "mssql", + "snowflake", "bigquery", "redshift", "pyspark", + "trino", "motherduck", "pyiceberg_rest"]: + assert name in DATABASES_REGISTRY, f"{name} missing from registry" + + def test_get_descriptor_returns_correct_type(self): + import mountainash_data.core.settings # noqa: F401 + desc = get_descriptor("sqlite") + assert desc.name == "sqlite" + + def test_get_settings_class_returns_correct_type(self): + import mountainash_data.core.settings # noqa: F401 + from mountainash_data.core.settings.sqlite import SQLiteAuthSettings + assert get_settings_class("sqlite") is SQLiteAuthSettings + + def test_legacy_REGISTRY_alias_still_works(self): + import mountainash_data.core.settings # noqa: F401 + assert "sqlite" in REGISTRY + assert REGISTRY["sqlite"].name == "sqlite" + # Iterate + names = list(REGISTRY.keys()) + assert "sqlite" in names