diff --git a/.github/config/mountainash_dependencies.yml b/.github/config/mountainash_dependencies.yml index c5d1c74..5447368 100644 --- a/.github/config/mountainash_dependencies.yml +++ b/.github/config/mountainash_dependencies.yml @@ -2,8 +2,8 @@ # Private Package Dependencies dependencies: - # - name: mountainash-constants - # org-name: mountainash-io + - name: mountainash-auth-client + org-name: mountainash-io # - name: mountainash-data # org-name: mountainash-io - name: mountainash-settings @@ -22,5 +22,5 @@ dependencies: # org-name: mountainash-io # - name: mountainash-utils-rules # org-name: mountainash-io - - name: mountainash-utils-ssh + - name: mountainash-secrets org-name: mountainash-io diff --git a/docs/superpowers/plans/2026-06-28-auth-client-migration.md b/docs/superpowers/plans/2026-06-28-auth-client-migration.md new file mode 100644 index 0000000..15df97c --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-auth-client-migration.md @@ -0,0 +1,1409 @@ +# Auth-Client Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate `mountainash-data` off the deleted `mountainash_settings.auth` onto `mountainash-auth-client`, with auth decoupled from the backend config profile and composed in a factory. + +**Architecture:** Three layers mirroring `mountainash-transport`: (L1) `*BackendProfile.emit(provider_type)` renders config only; (L2) `*AuthProfile` is pure credential data from auth-client; (L3) a data-owned `(provider_type, auth_class)→fn` dispatch table renders auth onto the config; the `ConnectionFactory` composes them and the runtime (`IbisBackend`/`IcebergConnection`) consumes a finished dict. No `register_adapter`, no `emit()` on auth profiles, no cross-package mutation. + +**Tech Stack:** Python 3.12, `mountainash-settings` 26.5.0 (`Profile`/`ProfileSpec`/`ParameterSpec`/`emit()`), `mountainash-auth-client` (`*AuthProfile`), Ibis 11, PyIceberg, hatch + uv, pytest. + +## Global Constraints + +- **Clean break, zero downstream consumers.** No backward-compat aliases, no deprecation shims. Old names are deleted, not aliased. +- **`provider_type` enum is `CONST_DB_PROVIDER_TYPE`** in `core/constants.py`; the PostgreSQL member is **`POSTGRESQL`** (NOT `POSTGRES`). Members: `MYSQL, POSTGRESQL, MSSQL, SNOWFLAKE, BIGQUERY, REDSHIFT, SQLITE, DUCKDB, MOTHERDUCK, TRINO, PYICEBERG_REST, ORACLE, CLICKHOUSE, DATABRICKS, SINGLESTOREDB, EXASOL, IMPALA, MATERIALIZE, RISINGWAVE, DRUID, PYSPARK`. +- **Auth profile fields are UPPERCASE `ParameterSpec` names**; secret fields are pydantic `SecretStr` (use `.get_secret_value()`); path fields are `Path | None` (use `str(...)` where a driver wants a string). +- **Auth profiles are pure data.** Data NEVER calls `auth_profile.emit()` and registers NOTHING onto auth-client classes. +- **`Profile.emit(target, *, base=None)`** runs `driver_key` renames via `_default_kwargs(target)`, then a 2-arg compose adapter from `__adapters__.get(target)` if present. A profile with any `__adapters__` is "target-scoped": `emit()` with no target raises. A profile WITHOUT `__adapters__` (bare-string `driver_key`s) accepts any explicit target — so `emit(provider_type)` is uniform across all 20 (confirmed: flat backends resolve bare driver_keys regardless of target; shaping backends route through their `__adapters__[provider_type]`). +- **Compose adapters ADD only.** Never overwrite a key `driver_key` produced, except the single sanctioned mssql `host` rewrite. Fields a compose folds carry NO flat `driver_key`. +- **Auth dispatch is MRO-aware** (`functools.singledispatch` semantics): most-specific registered base wins; two incomparable sibling registrations for one `provider_type` raise `TypeError`. +- **Fail-closed everywhere.** Unsupported auth → `ValueError`; supported-but-no-adapter → `ValueError`; unsupported URL auth → `NotImplementedError`. +- **Driver imports are LOCAL** to each adapter function (e.g. `from trino.auth import ...` inside the fn), so importing the settings layer never pulls `trino`/`google`. +- **Test integrity:** if a golden disagrees with the implementation, STOP and surface it — do not edit the test to pass. Never encode counts of backends as test assertions. +- **Commit trailer (every commit):** + ``` + Co-Authored-By: Claude Opus 4.8 (1M context) + ``` +- **Branch:** all work on `feature/auth-client-migration` (already checked out); PR targets `develop`. + +### Spec deviations carried by this plan (verified against the real tree) + +1. **4 shaping backends, not 3:** add **pyiceberg** (`HEADERS`→`header.` compose) to mysql/mssql/snowflake. pyiceberg's `s3.*`/`rest.*` become flat `driver_key`s. +2. **`POSTGRESQL`** is the real enum member (spec said `POSTGRES`). +3. **`core/factories/` does not exist on this branch** — Task 6 creates it fresh. (A different `ConnectionFactory` exists on the `settings-registry` worktree branch; flag at PR time for merge awareness.) +4. **hatch:** auth-client is absent from all envs (add it); `mountainash_utils_ssh` is a dead path-dep in `dev`/`build_github`/`test_github` (remove it). +5. **pyspark** is pure-flat — give params `driver_key`s, delete its adapter. +6. **`UrlParts` lives in `core/settings/profile.py`** (the L1 output type), imported by the factory — NOT in the factory — so the settings flip doesn't depend on the factory. + +### CRITICAL ordering constraint (why the flip is Task 2) + +At HEAD the suite fails at collection: `core/settings/__init__.py` and every backend module import the deleted `mountainash_settings.auth`. **Importing ANY submodule of `core.settings` runs `core/settings/__init__.py` first**, so until the whole settings layer is migrated, *nothing* under `core.settings` — including new adapter/registry modules placed there — can be imported or tested. Therefore the atomic settings flip (Task 2) MUST precede the adapter/registry/factory tasks. After Task 2, `import mountainash_data.core.settings` succeeds and every later task's tests can collect. Full green (`hatch run test:test`) is asserted in Task 9. + +--- + +## File Structure + +**New files** +- `core/settings/adapters/sql.py` — shared flat `userpass(auth, base)`. +- `core/settings/adapters/registry.py` — `_AUTH_ADAPTERS` + MRO `auth_adapter()`. +- `core/factories/__init__.py`, `core/factories/connection_factory.py` — compose, URL appliers, `apply_auth_adapter`, dialect/scheme→provider helpers. +- Tests under `tests/test_unit/core/settings/adapters/`, `tests/test_unit/core/factories/`. + +**Heavily modified** +- `core/settings/descriptor.py` (`supported_auth`), `core/settings/profile.py` (`BackendProfile` + `UrlParts` + `to_url_parts`), `core/settings/__init__.py` (import swap), the 20 backend modules, the per-backend adapter modules, `backends/ibis/backend.py`, `backends/iceberg/connection.py`, `hatch.toml`, `pyproject.toml`. + +**Deleted** +- `core/settings/adapters/pyspark.py`, `core/settings/auth/` (3 shim files). + +--- + +## Task 1: Dependency wiring + +**Files:** Modify `pyproject.toml`, `hatch.toml`. + +**Interfaces:** Produces `mountainash_auth_client` importable in all hatch envs. + +- [ ] **Step 1: Add auth-client to `pyproject.toml`** + +In `[project] dependencies`, after `"sqlalchemy",`, add: +```toml + "mountainash-auth-client", +``` + +- [ ] **Step 2: Wire auth-client + remove dead utils-ssh in `hatch.toml`** + +In `envs.dev` and `envs.test`, add (local format) and DELETE any `mountainash_utils_ssh` line: +```toml + "mountainash_auth_client @ {root:uri}/../mountainash-auth-client", +``` +In `envs.test_github` and `envs.build_github`, add (CI format) and DELETE their `mountainash_utils_ssh` lines: +```toml + "mountainash_auth_client @ {root:uri}/temp/mountainash-auth-client", +``` + +- [ ] **Step 3: Verify auth-client imports** + +Run: `hatch run test:python -c "import mountainash_auth_client as a; print(a.PasswordAuthProfile, a.NoAuthProfile, a.AuthProfile)"` +Expected: prints the three classes. If env is stale: `hatch env prune` then re-run. + +- [ ] **Step 4: Confirm utils-ssh gone** + +Run: `grep -rn "mountainash_utils_ssh" hatch.toml` +Expected: no output. + +- [ ] **Step 5: Commit** +```bash +git add pyproject.toml hatch.toml +git commit -m "build: add mountainash-auth-client dep; drop dead utils-ssh path-dep + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: Settings core flip (descriptor + base profile + 20 backends + __init__) + +The irreducible atomic flip — `__init__.py` imports the renamed backends, which import the renamed base, which uses the new descriptor. After this task `import mountainash_data.core.settings` succeeds and flat backends emit correct config. The 4 shaping backends import but emit config WITHOUT their nested keys (completed in Task 3). + +**Files:** +- Modify: `core/settings/descriptor.py`, `core/settings/profile.py`, all 20 backend modules, `core/settings/__init__.py` +- Delete: `core/settings/auth/` (3 files), `core/settings/adapters/pyspark.py` +- Test: `tests/test_unit/core/settings/test_settings_flip.py` + +**Interfaces:** +- Produces: `UrlParts` (in `profile.py`); `BackendProfile` base with `to_url_parts()`; `BackendSpec.supported_auth: tuple[type, ...]`; 20 `*BackendProfile` classes (rename table below). + +- [ ] **Step 1: Add `supported_auth` to `descriptor.py`** +```python +@dataclass(frozen=True, kw_only=True) +class BackendSpec(ProfileSpec): + default_port: int | None = None + connection_string_scheme: str | None = None + ibis_dialect: str | None = None + rides_on: str | None = None + supported_auth: tuple[type, ...] = () + + def __post_init__(self) -> None: + if not self.supported_auth: + raise ValueError(f"{self.name}: supported_auth must be non-empty") +``` +> If `ProfileSpec` defines `__post_init__`, call `super().__post_init__()` first. Check: `hatch run test:python -c "from mountainash_settings.profiles import ProfileSpec; print(hasattr(ProfileSpec,'__post_init__'))"`. + +- [ ] **Step 2: Rewrite `profile.py` — `UrlParts` + `BackendProfile`** +```python +from dataclasses import dataclass, field +# ... keep existing Profile / lookup_class_var imports; REMOVE the quote import. + + +@dataclass(frozen=True) +class UrlParts: + """Credential-free URL skeleton (L1). Every authority component optional.""" + scheme: str + database: str | None = None + host: str | None = None + port: int | None = None + path: str | None = None + query: dict[str, str] = field(default_factory=dict) + + +class BackendProfile(Profile): + """Database backend CONFIG. Pure L1 emitter — no auth methods. + + Auth is orthogonal, applied by ConnectionFactory, never here. + """ + + def to_url_parts(self) -> UrlParts: + desc = lookup_class_var(type(self), "__spec__") + scheme = getattr(desc, "connection_string_scheme", None) + if scheme is None: + raise NotImplementedError(f"Profile {self.backend!r} has no URL form") + scheme = scheme.removesuffix("://").removesuffix(":") + return UrlParts( + scheme=scheme, + host=getattr(self, "HOST", None), + port=getattr(self, "PORT", None), + database=getattr(self, "DATABASE", None), + ) +``` +Remove `to_driver_kwargs`, `to_connection_string`, `_auth_kwargs`/`__adapter__` references. + +- [ ] **Step 3: The 16 flat backends — rename + import-swap + supported_auth** + +For each, apply: (1) `from mountainash_settings.auth import (...)` → `from mountainash_auth_client import ()`; (2) `from .profile import ConnectionProfile` → `from .profile import BackendProfile`; (3) replace `auth_modes=[...]` with `supported_auth=(),`; (4) rename `class AuthSettings(ConnectionProfile):` → `class BackendProfile(BackendProfile):`. + +Worked example — `postgresql.py`: +```python +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile +from .profile import BackendProfile +# ...inside POSTGRESQL_SPEC: + supported_auth=(PasswordAuthProfile, NoAuthProfile), +# ... +@register +class PostgreSQLBackendProfile(BackendProfile): + __spec__ = POSTGRESQL_SPEC +``` + +| File | Old → New class | `supported_auth=` | +|---|---|---| +| `postgresql.py` | `PostgreSQLAuthSettings` → `PostgreSQLBackendProfile` | `(PasswordAuthProfile, NoAuthProfile)` | +| `clickhouse.py` | `ClickHouseAuthSettings` → `ClickHouseBackendProfile` | `(PasswordAuthProfile, NoAuthProfile)` | +| `singlestoredb.py` | `SingleStoreDBAuthSettings` → `SingleStoreDBBackendProfile` | `(PasswordAuthProfile, NoAuthProfile)` | +| `druid.py` | `DruidAuthSettings` → `DruidBackendProfile` | `(PasswordAuthProfile, NoAuthProfile)` | +| `impala.py` | `ImpalaAuthSettings` → `ImpalaBackendProfile` | `(PasswordAuthProfile, NoAuthProfile)` | +| `materialize.py` | `MaterializeAuthSettings` → `MaterializeBackendProfile` | `(PasswordAuthProfile, NoAuthProfile)` | +| `risingwave.py` | `RisingWaveAuthSettings` → `RisingWaveBackendProfile` | `(PasswordAuthProfile, NoAuthProfile)` | +| `exasol.py` | `ExasolAuthSettings` → `ExasolBackendProfile` | `(PasswordAuthProfile,)` | +| `sqlite.py` | `SQLiteAuthSettings` → `SQLiteBackendProfile` | `(NoAuthProfile,)` | +| `duckdb.py` | `DuckDBAuthSettings` → `DuckDBBackendProfile` | `(NoAuthProfile,)` | +| `redshift.py` | `RedshiftAuthSettings` → `RedshiftBackendProfile` | `(PasswordAuthProfile, IAMAuthProfile)` | +| `databricks.py` | `DatabricksAuthSettings` → `DatabricksBackendProfile` | `(TokenAuthProfile, PasswordAuthProfile, NoAuthProfile)` | +| `trino.py` | `TrinoAuthSettings` → `TrinoBackendProfile` | `(PasswordAuthProfile, JWTAuthProfile, KerberosAuthProfile, NoAuthProfile)` | +| `bigquery.py` | `BigQueryAuthSettings` → `BigQueryBackendProfile` | `(ServiceAccountAuthProfile, NoAuthProfile)` | + +> `redshift/databricks/trino/bigquery` carry `__adapter__ = staticmethod(_adapter.build_driver_kwargs)` + a `from .adapters import X as _adapter` line — **DELETE both** (their config is flat `driver_key`s; auth moves to the registry). No `__adapters__` needed for them. + +- [ ] **Step 4: pyspark — flat, delete adapter, add `driver_key`s** + +`pyspark.py`: delete `from .adapters import pyspark as _adapter` + the `__adapter__` line; rename → `PySparkBackendProfile`; `supported_auth=(NoAuthProfile,)`; add `driver_key`s: +```python + ParameterSpec(name="SESSION", type=t.Optional[t.Any], tier="core", default=None, driver_key="session"), + ParameterSpec(name="MODE", type=PySparkMode, tier="core", default=PySparkMode.BATCH, driver_key="mode"), + ParameterSpec(name="SPARK_MASTER", type=t.Optional[str], tier="advanced", default=None, driver_key="spark.master"), + ParameterSpec(name="APPLICATION_NAME", type=t.Optional[str], tier="advanced", default=None, driver_key="spark.app.name"), + ParameterSpec(name="WAREHOUSE_DIR", type=t.Optional[str], tier="advanced", default=None, driver_key="spark.sql.warehouse.dir"), + ParameterSpec(name="PARTITIONS", type=t.Optional[int], tier="advanced", default=None, driver_key="spark.sql.shuffle.partitions"), +``` +Update `__all__` → `["PySparkBackendProfile", "PySparkMode", "PYSPARK_SPEC"]`. `git rm core/settings/adapters/pyspark.py`. + +- [ ] **Step 5: motherduck — TokenAuth + URL override** + +`motherduck.py`: rename → `MotherDuckBackendProfile`; `supported_auth=(TokenAuthProfile,)`; import `TokenAuthProfile`; override (scheme `"duckdb://md:"` would mangle under the base logic): +```python + def to_url_parts(self): + from .profile import UrlParts + return UrlParts(scheme="md", database=getattr(self, "DATABASE", None)) +``` + +- [ ] **Step 6: The 4 shaping backends — rename only (compose in Task 3)** + +`mysql.py`/`mssql.py`/`snowflake.py`/`pyiceberg_rest.py`: apply rename + import-swap + supported_auth, and **DELETE** the `__adapter__` line + `from .adapters import X as _adapter` import. Do NOT add `__adapters__` yet. +- `mysql.py` → `MySQLBackendProfile`, `(PasswordAuthProfile,)` +- `mssql.py` → `MSSQLBackendProfile`, `(PasswordAuthProfile, WindowsAuthProfile, AzureADAuthProfile)` +- `snowflake.py` → `SnowflakeBackendProfile`, `(PasswordAuthProfile, OAuth2AuthProfile, CertificateAuthProfile, TokenAuthProfile)` +- `pyiceberg_rest.py` → `PyIcebergRestBackendProfile`, `(TokenAuthProfile,)` + +- [ ] **Step 7: Delete the auth shim** +```bash +git rm core/settings/auth/__init__.py core/settings/auth/base.py core/settings/auth/dispatch.py +``` + +- [ ] **Step 8: Rewrite `core/settings/__init__.py`** + +Replace the `from mountainash_settings.auth import (...)` block with: +```python +from mountainash_auth_client import ( + AuthProfile, NoAuthProfile, PasswordAuthProfile, TokenAuthProfile, + JWTAuthProfile, OAuth2AuthProfile, IAMAuthProfile, WindowsAuthProfile, + AzureADAuthProfile, KerberosAuthProfile, CertificateAuthProfile, + ServiceAccountAuthProfile, +) +``` +Change `from .profile import ConnectionProfile` → `from .profile import BackendProfile, UrlParts`. Rewrite the 20 backend imports to the new names. Rewrite `__all__`: drop every `*Auth`/`AuthSpec`/`*AuthSettings` name; add the `*AuthProfile` names + `AuthProfile`; add `"UrlParts"`; replace `"ConnectionProfile"` with `"BackendProfile"`; list the 20 `*BackendProfile` names. + +- [ ] **Step 9: Write the smoke test** + +`tests/test_unit/core/settings/test_settings_flip.py`: +```python +import pytest +from mountainash_auth_client import PasswordAuthProfile, NoAuthProfile +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings import ( + BackendProfile, PostgreSQLBackendProfile, MotherDuckBackendProfile, +) +from mountainash_data.core.settings.descriptor import BackendSpec + + +def test_supported_auth_present(): + assert PostgreSQLBackendProfile.__spec__.supported_auth == (PasswordAuthProfile, NoAuthProfile) + + +def test_flat_emit_is_config_only(): + out = PostgreSQLBackendProfile(HOST="db", PORT=5432, DATABASE="app").emit(P.POSTGRESQL) + assert out["host"] == "db" and out["port"] == 5432 and out["database"] == "app" + assert "user" not in out and "password" not in out + + +def test_to_url_parts_standard(): + parts = PostgreSQLBackendProfile(HOST="db", PORT=5432, DATABASE="app").to_url_parts() + assert (parts.scheme, parts.host, parts.port, parts.database) == ("postgresql", "db", 5432, "app") + + +def test_motherduck_url_parts_authority_less(): + parts = MotherDuckBackendProfile(DATABASE="mydb").to_url_parts() + assert parts.scheme == "md" and parts.host is None and parts.database == "mydb" + + +def test_empty_supported_auth_invariant(): + with pytest.raises(ValueError, match="supported_auth"): + BackendSpec(name="x", provider_type=P.SQLITE, parameters=[], supported_auth=()) +``` + +- [ ] **Step 10: Verify settings imports + smoke passes** + +Run: `hatch run test:python -c "import mountainash_data.core.settings as s; print(sum(1 for n in dir(s) if n.endswith('BackendProfile')))"` +Expected: `20`. +Run: `hatch run test:test-target tests/test_unit/core/settings/test_settings_flip.py -q` +Expected: PASS. + +- [ ] **Step 11: Commit** +```bash +git add -A core/settings/ tests/test_unit/core/settings/test_settings_flip.py +git commit -m "refactor(settings)!: flip to *BackendProfile + supported_auth; drop auth shim + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: Config-shaping compose adapters (mysql, mssql, snowflake, pyiceberg) + +**Files:** +- Rewrite: `core/settings/adapters/{mysql,mssql,snowflake,pyiceberg_rest}.py` (compose fn ONLY this task; auth fns in Task 4) +- Modify: `core/settings/{mysql,mssql,snowflake,pyiceberg_rest}.py` (wire `__adapters__`; pyiceberg `driver_key`s) +- Test: `tests/test_unit/core/settings/test_config_shaping.py` + +**Interfaces:** Produces `mysql.ssl_compose`, `mssql.host_fold`, `snowflake.session_params`, `pyiceberg_rest.headers_compose` — each `(profile, base) -> dict`. + +- [ ] **Step 1: Write failing goldens (full-dict equality = mechanical key-delta)** + +`tests/test_unit/core/settings/test_config_shaping.py`: +```python +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings import ( + MySQLBackendProfile, MSSQLBackendProfile, SnowflakeBackendProfile, + PyIcebergRestBackendProfile, +) + + +def test_mysql_ssl_compose_full_dict(): + out = MySQLBackendProfile(HOST="h", PORT=3306, SSL_CA="/ca.pem", SSL_CIPHER="HIGH").emit(P.MYSQL) + # full equality: nested ssl ADDED, no flat ssl_* leaked, config unchanged + assert out == { + "host": "h", "port": 3306, "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", "autocommit": True, + "ssl": {"ssl-ca": "/ca.pem", "ssl-cipher": "HIGH"}, + } + + +def test_mssql_host_fold_full_dict(): + out = MSSQLBackendProfile(HOST="srv", PORT=1433, INSTANCE_NAME="INST").emit(P.MSSQL) + assert out["host"] == "srv\\INST" and "instance_name" not in out + + +def test_snowflake_session_parameters_added_only(): + out = SnowflakeBackendProfile(ACCOUNT="acct", QUERY_TAG="etl", TIMEZONE="UTC").emit(P.SNOWFLAKE) + assert out["session_parameters"] == {"QUERY_TAG": "etl", "TIMEZONE": "UTC"} + assert "query_tag" not in out and "timezone" not in out + + +def test_pyiceberg_headers_expand_s3_flat(): + out = PyIcebergRestBackendProfile( + CATALOG_NAME="c", CATALOG_URI="http://x", S3_REGION="us-east-1", + HEADERS={"X-A": "1", "X-B": "2"}, + ).emit(P.PYICEBERG_REST) + assert out["name"] == "c" and out["uri"] == "http://x" and out["s3.region"] == "us-east-1" + assert out["header.X-A"] == "1" and out["header.X-B"] == "2" and "headers" not in out +``` +> Confirm exact flat defaults in `test_mysql_ssl_compose_full_dict` against `mysql.py` params (charset/collation/autocommit). If they differ, fix the EXPECTED dict to match the real spec — that is reading ground truth, not weakening the test. + +- [ ] **Step 2: Run to verify it fails** + +Run: `hatch run test:test-target tests/test_unit/core/settings/test_config_shaping.py -q` +Expected: FAIL — composes not wired. + +- [ ] **Step 3: Write the compose functions** + +`core/settings/adapters/mysql.py` (replace the file's old `build_driver_kwargs`): +```python +"""MySQL config-shaping adapter.""" +from __future__ import annotations +import typing as t + + +def ssl_compose(profile: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = dict(base) + if profile.SSL_MODE is not None: + out["ssl_mode"] = str(profile.SSL_MODE) + ssl: dict[str, str] = {} + for key, val in { + "ssl-key": profile.SSL_KEY, "ssl-cert": profile.SSL_CERT, + "ssl-ca": profile.SSL_CA, "ssl-capath": profile.SSL_CAPATH, + "ssl-cipher": profile.SSL_CIPHER, + }.items(): + if val is not None: + ssl[key] = str(val) + if ssl: + out["ssl"] = ssl + return out +``` +`core/settings/adapters/mssql.py` (compose part — auth fns appended in Task 4): +```python +"""MSSQL adapters.""" +from __future__ import annotations +import typing as t + + +def host_fold(profile: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = dict(base) + if profile.INSTANCE_NAME: + out["host"] = f"{out['host']}\\{profile.INSTANCE_NAME}" + if profile.ENCRYPTION is not None: + out["encrypt"] = str(profile.ENCRYPTION) + if profile.TRUST_SERVER_CERTIFICATE: + out["trust_server_certificate"] = "yes" + if profile.MARS_ENABLED: + out["mars_connection"] = "yes" + return out +``` +`core/settings/adapters/snowflake.py` (compose part — auth fns appended in Task 4): +```python +"""Snowflake adapters.""" +from __future__ import annotations +import typing as t + + +def session_params(profile: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = dict(base) + params: dict[str, t.Any] = {} + if profile.QUERY_TAG is not None: + params["QUERY_TAG"] = profile.QUERY_TAG + if profile.TIMEZONE is not None: + params["TIMEZONE"] = profile.TIMEZONE + if params: + out["session_parameters"] = params + return out +``` +`core/settings/adapters/pyiceberg_rest.py` (compose part — auth fn appended in Task 4): +```python +"""PyIceberg REST adapters.""" +from __future__ import annotations +import typing as t + + +def headers_compose(profile: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = dict(base) + if profile.HEADERS: + for hk, hv in profile.HEADERS.items(): + out[f"header.{hk}"] = hv + return out +``` + +- [ ] **Step 4: Wire `__adapters__` + pyiceberg `driver_key`s** + +`mysql.py`: `from .adapters import mysql as _mysql` and in the class body `__adapters__ = {CONST_DB_PROVIDER_TYPE.MYSQL: _mysql.ssl_compose}`. +`mssql.py`: `from .adapters import mssql as _mssql`; `__adapters__ = {CONST_DB_PROVIDER_TYPE.MSSQL: _mssql.host_fold}`. +`snowflake.py`: `from .adapters import snowflake as _snow`; `__adapters__ = {CONST_DB_PROVIDER_TYPE.SNOWFLAKE: _snow.session_params}`. +`pyiceberg_rest.py`: add `driver_key`s to the s3/rest params (`S3_REGION→"s3.region"`, `S3_ENDPOINT→"s3.endpoint"`, `S3_ACCESS_KEY_ID→"s3.access-key-id"`, `S3_SECRET_ACCESS_KEY→"s3.secret-access-key"` keep `secret=True`, `S3_SESSION_TOKEN→"s3.session-token"` keep `secret=True`, `REST_SIGV4_ENABLED→"rest.sigv4-enabled"`, `REST_SIGNING_REGION→"rest.signing-region"`, `REST_SIGNING_NAME→"rest.signing-name"`; `HEADERS` keeps NO driver_key), then `from .adapters import pyiceberg_rest as _ice`; `__adapters__ = {CONST_DB_PROVIDER_TYPE.PYICEBERG_REST: _ice.headers_compose}`. + +- [ ] **Step 5: Run goldens** + +Run: `hatch run test:test-target tests/test_unit/core/settings/test_config_shaping.py -q` +Expected: PASS. + +- [ ] **Step 6: Commit** +```bash +git add core/settings/ +git commit -m "feat(settings): config-shaping compose adapters (mysql/mssql/snowflake/pyiceberg) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: Auth adapter functions + +**Files:** +- Create: `core/settings/adapters/sql.py` +- Append auth fns to `core/settings/adapters/{trino,snowflake,mssql,redshift,databricks,bigquery,pyiceberg_rest}.py` +- Test: `tests/test_unit/core/settings/adapters/test_auth_adapters.py` + +**Interfaces:** Each is `(auth_profile, base: dict) -> dict`, returns a NEW dict, never mutates `base`. Produces: `sql.userpass`; `trino.{password,jwt,kerberos}`; `snowflake.{password,token,oauth2,certificate}`; `mssql.{password,windows,azure_ad}`; `redshift.{password,iam}`; `databricks.{token,password}`; `bigquery.service_account`; `pyiceberg_rest.token`. + +> `sql.userpass` emits `{user,password}` — confirmed correct for all 9 flat backends (ibis `do_connect`); databricks uses `{username,password}` via its own adapter. + +- [ ] **Step 1: Write failing tests** + +`tests/test_unit/core/settings/adapters/test_auth_adapters.py`: +```python +import pytest +from mountainash_auth_client import ( + PasswordAuthProfile, TokenAuthProfile, OAuth2AuthProfile, + CertificateAuthProfile, WindowsAuthProfile, AzureADAuthProfile, + IAMAuthProfile, ServiceAccountAuthProfile, +) +from mountainash_data.core.settings.adapters import ( + sql as _sql, snowflake as _snow, mssql as _mssql, + redshift as _rs, databricks as _dbx, pyiceberg_rest as _ice, +) + + +def test_sql_userpass(): + assert _sql.userpass(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), {"host": "h"}) == { + "host": "h", "user": "u", "password": "p"} + + +def test_userpass_no_mutate(): + base = {"host": "h"} + _sql.userpass(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), base) + assert base == {"host": "h"} + + +def test_snowflake_token_oauth(): + assert _snow.token(TokenAuthProfile(TOKEN="t"), {}) == {"authenticator": "oauth", "token": "t"} + + +def test_snowflake_oauth2_token_only(): + assert _snow.oauth2(OAuth2AuthProfile(TOKEN="t"), {}) == {"authenticator": "oauth", "token": "t"} + + +def test_snowflake_password(): + assert _snow.password(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), {}) == {"user": "u", "password": "p"} + + +def test_snowflake_certificate(): + assert _snow.certificate(CertificateAuthProfile(PRIVATE_KEY="KEY", PASSPHRASE="ph"), {}) == { + "private_key": "KEY", "private_key_file_pwd": "ph"} + + +def test_mssql_password(): + assert _mssql.password(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), {}) == {"user": "u", "password": "p"} + + +def test_mssql_windows(): + assert _mssql.windows(WindowsAuthProfile(USERNAME="u", DOMAIN="D"), {}) == { + "trusted_connection": "yes", "user": "D\\u"} + + +def test_mssql_azure_ad_sp(): + assert _mssql.azure_ad(AzureADAuthProfile(CLIENT_ID="cid", CLIENT_SECRET="sec", TENANT_ID="t"), {}) == { + "authentication": "ActiveDirectoryServicePrincipal", "user_id": "cid", + "password": "sec", "tenant_id": "t"} + + +def test_redshift_iam(): + assert _rs.iam(IAMAuthProfile(ROLE_ARN="arn", ACCESS_KEY_ID="ak"), {}) == { + "iam": True, "iam_role_arn": "arn", "aws_access_key_id": "ak"} + + +def test_databricks_token(): + assert _dbx.token(TokenAuthProfile(TOKEN="tok"), {}) == {"access_token": "tok"} + + +def test_pyiceberg_token(): + assert _ice.token(TokenAuthProfile(TOKEN="tok"), {"uri": "u"}) == {"uri": "u", "token": "tok"} + + +def test_trino_password_builds_basic_auth(): + pytest.importorskip("trino") + from trino.auth import BasicAuthentication + from mountainash_data.core.settings.adapters import trino as _trino + out = _trino.password(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), {"host": "h"}) + assert out["host"] == "h" and out["user"] == "u" and isinstance(out["auth"], BasicAuthentication) + + +def test_bigquery_service_account(monkeypatch): + pytest.importorskip("google.oauth2") + from google.oauth2 import service_account as _sa + from mountainash_data.core.settings.adapters import bigquery as _bq + sentinel = object() + monkeypatch.setattr(_sa.Credentials, "from_service_account_info", classmethod(lambda cls, info: sentinel)) + assert _bq.service_account(ServiceAccountAuthProfile(INFO={"k": "v"}), {}) == {"credentials": sentinel} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `hatch run test:test-target tests/test_unit/core/settings/adapters/test_auth_adapters.py -q` +Expected: FAIL. + +- [ ] **Step 3: Implement `sql.py`** +```python +"""Shared auth adapter for flat user/password SQL backends.""" +from __future__ import annotations +import typing as t + + +def userpass(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} +``` + +- [ ] **Step 4: Append auth fns to the per-backend adapter modules** + +Append to `snowflake.py`: +```python +def password(auth, base): + return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} + + +def token(auth, base): + return {**base, "authenticator": "oauth", "token": auth.TOKEN.get_secret_value()} + + +def oauth2(auth, base): + # token-only: never reads CLIENT_ID/SECRET/SERVER_URI/SCOPE (smell #1) + return {**base, "authenticator": "oauth", "token": auth.TOKEN.get_secret_value()} + + +def certificate(auth, base): + out = dict(base) + if auth.PRIVATE_KEY is not None: + out["private_key"] = auth.PRIVATE_KEY.get_secret_value() + if auth.PRIVATE_KEY_PATH is not None: + out["private_key_file"] = str(auth.PRIVATE_KEY_PATH) + if auth.PASSPHRASE is not None: + out["private_key_file_pwd"] = auth.PASSPHRASE.get_secret_value() + return out +``` +Append to `mssql.py`: +```python +def password(auth, base): + return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} + + +def windows(auth, base): + out = {**base, "trusted_connection": "yes"} + if auth.DOMAIN and auth.USERNAME: + out["user"] = f"{auth.DOMAIN}\\{auth.USERNAME}" + elif auth.USERNAME: + out["user"] = auth.USERNAME + return out + + +def azure_ad(auth, base): + out = dict(base) + if auth.MANAGED_IDENTITY: + out["authentication"] = "ActiveDirectoryMsi" + if auth.MSI_ENDPOINT: + out["msi_endpoint"] = auth.MSI_ENDPOINT + else: + out["authentication"] = "ActiveDirectoryServicePrincipal" + if auth.CLIENT_ID: + out["user_id"] = auth.CLIENT_ID + if auth.CLIENT_SECRET: + out["password"] = auth.CLIENT_SECRET.get_secret_value() + if auth.TENANT_ID: + out["tenant_id"] = auth.TENANT_ID + return out +``` +Append to `pyiceberg_rest.py`: +```python +def token(auth, base): + return {**base, "token": auth.TOKEN.get_secret_value()} +``` +Create/replace `redshift.py`, `databricks.py`, `trino.py`, `bigquery.py` (these are NOT shaping, so the whole file is auth fns): +```python +# redshift.py +def password(auth, base): + return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} + + +def iam(auth, base): + out = {**base, "iam": True} + 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() + if auth.PROFILE_NAME is not None: out["profile_name"] = auth.PROFILE_NAME + return out +``` +```python +# databricks.py +def token(auth, base): + return {**base, "access_token": auth.TOKEN.get_secret_value()} + + +def password(auth, base): + return {**base, "username": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} +``` +```python +# trino.py +def password(auth, base): + from trino.auth import BasicAuthentication + return {**base, "user": auth.USERNAME, + "auth": BasicAuthentication(auth.USERNAME, auth.PASSWORD.get_secret_value())} + + +def jwt(auth, base): + from trino.auth import JWTAuthentication + return {**base, "auth": JWTAuthentication(auth.TOKEN.get_secret_value())} + + +def kerberos(auth, base): + from trino.auth import KerberosAuthentication + return {**base, "auth": KerberosAuthentication(config=None, service_name=auth.SERVICE_NAME, principal=auth.PRINCIPAL)} +``` +```python +# bigquery.py +def service_account(auth, base): + from google.oauth2 import service_account as _sa + out = dict(base) + if auth.INFO is not None: + out["credentials"] = _sa.Credentials.from_service_account_info(auth.INFO) + elif auth.FILE is not None: + out["credentials"] = _sa.Credentials.from_service_account_file(str(auth.FILE)) + return out +``` +(Each file starts with `from __future__ import annotations` and `import typing as t` where types are referenced; the shaping files keep their compose fn from Task 3.) + +- [ ] **Step 5: Run tests** + +Run: `hatch run test:test-target tests/test_unit/core/settings/adapters/test_auth_adapters.py -q` +Expected: PASS (trino/bigquery skip without extras). + +- [ ] **Step 6: Commit** +```bash +git add core/settings/adapters/ tests/test_unit/core/settings/adapters/test_auth_adapters.py +git commit -m "feat(settings): data-owned auth adapter functions + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Auth dispatch registry (MRO-aware) + +**Files:** Create `core/settings/adapters/registry.py`; Test `tests/test_unit/core/settings/adapters/test_registry.py`. + +**Interfaces:** Produces `auth_adapter(provider_type, auth_class) -> Callable | None` (MRO-aware; `TypeError` on sibling ambiguity); `_AUTH_ADAPTERS`. + +- [ ] **Step 1: Write failing tests** + +`tests/test_unit/core/settings/adapters/test_registry.py`: +```python +import pytest +from mountainash_auth_client import PasswordAuthProfile, TokenAuthProfile, NoAuthProfile +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings.adapters import sql as _sql, snowflake as _snow +from mountainash_data.core.settings.adapters.registry import auth_adapter, _AUTH_ADAPTERS + + +def test_exact_lookup(): + assert auth_adapter(P.SNOWFLAKE, TokenAuthProfile) is _snow.token + + +def test_flat_userpass_shared(): + assert auth_adapter(P.POSTGRESQL, PasswordAuthProfile) is _sql.userpass + + +def test_miss_returns_none(): + assert auth_adapter(P.SQLITE, PasswordAuthProfile) is None + + +def test_noauth_not_in_table(): + assert all(k[1] is not NoAuthProfile for k in _AUTH_ADAPTERS) + + +def test_subclass_resolves_to_base(): + class MyPw(PasswordAuthProfile): pass + assert auth_adapter(P.POSTGRESQL, MyPw) is _sql.userpass + + +def test_specialization_wins(): + fn = lambda a, b: b + class Special(PasswordAuthProfile): pass + _AUTH_ADAPTERS[(P.POSTGRESQL, Special)] = fn + try: + assert auth_adapter(P.POSTGRESQL, Special) is fn + finally: + del _AUTH_ADAPTERS[(P.POSTGRESQL, Special)] + + +def test_sibling_ambiguity_raises(): + fn = lambda a, b: b + _AUTH_ADAPTERS[(P.POSTGRESQL, TokenAuthProfile)] = fn + class Hybrid(PasswordAuthProfile, TokenAuthProfile): pass + try: + with pytest.raises(TypeError, match="ambiguous"): + auth_adapter(P.POSTGRESQL, Hybrid) + finally: + del _AUTH_ADAPTERS[(P.POSTGRESQL, TokenAuthProfile)] +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `hatch run test:test-target tests/test_unit/core/settings/adapters/test_registry.py -q` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement `registry.py`** +```python +"""Data-owned auth dispatch: (provider_type, auth_class) -> adapter fn.""" +from __future__ import annotations +import typing as t + +from mountainash_auth_client import ( + PasswordAuthProfile, JWTAuthProfile, KerberosAuthProfile, + ServiceAccountAuthProfile, IAMAuthProfile, TokenAuthProfile, + OAuth2AuthProfile, CertificateAuthProfile, WindowsAuthProfile, AzureADAuthProfile, +) +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from . import (sql as _sql, trino as _trino, snowflake as _snow, bigquery as _bq, + databricks as _dbx, mssql as _mssql, redshift as _rs, pyiceberg_rest as _ice) + +_AUTH_ADAPTERS: dict[tuple[t.Any, type], t.Callable[[t.Any, dict], dict]] = { + (P.TRINO, PasswordAuthProfile): _trino.password, + (P.TRINO, JWTAuthProfile): _trino.jwt, + (P.TRINO, KerberosAuthProfile): _trino.kerberos, + (P.SNOWFLAKE, PasswordAuthProfile): _snow.password, + (P.SNOWFLAKE, TokenAuthProfile): _snow.token, + (P.SNOWFLAKE, OAuth2AuthProfile): _snow.oauth2, + (P.SNOWFLAKE, CertificateAuthProfile): _snow.certificate, + (P.BIGQUERY, ServiceAccountAuthProfile): _bq.service_account, + (P.DATABRICKS, TokenAuthProfile): _dbx.token, + (P.DATABRICKS, PasswordAuthProfile): _dbx.password, + (P.MSSQL, PasswordAuthProfile): _mssql.password, + (P.MSSQL, WindowsAuthProfile): _mssql.windows, + (P.MSSQL, AzureADAuthProfile): _mssql.azure_ad, + (P.REDSHIFT, PasswordAuthProfile): _rs.password, + (P.REDSHIFT, IAMAuthProfile): _rs.iam, + (P.PYICEBERG_REST, TokenAuthProfile): _ice.token, +} +for _p in (P.POSTGRESQL, P.MYSQL, P.CLICKHOUSE, P.MATERIALIZE, P.RISINGWAVE, + P.DRUID, P.SINGLESTOREDB, P.IMPALA, P.EXASOL): + _AUTH_ADAPTERS[(_p, PasswordAuthProfile)] = _sql.userpass + + +def auth_adapter(provider_type: t.Any, auth_class: type) -> t.Callable[[t.Any, dict], dict] | None: + matches = [k for k in auth_class.__mro__ if (provider_type, k) in _AUTH_ADAPTERS] + if not matches: + return None + winner = matches[0] + ambiguous = [k for k in matches[1:] if not issubclass(winner, k)] + if ambiguous: + raise TypeError( + f"ambiguous auth adapter for {auth_class.__name__} on {provider_type}: " + f"{winner.__name__} vs {[k.__name__ for k in ambiguous]} " + f"(multiply-inherits unrelated registered auth types)" + ) + return _AUTH_ADAPTERS[(provider_type, winner)] +``` + +- [ ] **Step 4: Run tests** + +Run: `hatch run test:test-target tests/test_unit/core/settings/adapters/test_registry.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** +```bash +git add core/settings/adapters/registry.py tests/test_unit/core/settings/adapters/test_registry.py +git commit -m "feat(settings): MRO-aware auth dispatch registry + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 6: ConnectionFactory (compose + URL + non-profile auth) + +**Files:** Create `core/factories/__init__.py`, `core/factories/connection_factory.py`; Test `tests/test_unit/core/factories/test_connection_factory.py`. + +**Interfaces:** +- Consumes: `auth_adapter` (Task 5); `UrlParts` (from `core.settings.profile`); a profile with `.emit(target)`, `.to_url_parts()`, `.__spec__.{provider_type,supported_auth}`, `.backend`. +- Produces: + - `_normalize_and_validate_auth(profile, auth) -> AuthProfile` + - `build_driver_kwargs(profile, auth_profile=None) -> dict` + - `build_connection_string(profile, auth_profile=None) -> str` + - `apply_auth_adapter(provider_type, base, auth_profile) -> dict` — non-profile auth application (for the ibis dialect/URL paths, no `supported_auth` to validate). + - `provider_for_dialect(dialect) -> provider_type`, `provider_for_scheme(scheme) -> provider_type` — derived from the registered specs. + +- [ ] **Step 1: Write failing tests** + +`tests/test_unit/core/factories/test_connection_factory.py`: +```python +import pytest +from dataclasses import dataclass + +from mountainash_auth_client import ( + PasswordAuthProfile, TokenAuthProfile, NoAuthProfile, WindowsAuthProfile, +) +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings.profile import UrlParts +from mountainash_data.core.factories.connection_factory import ( + build_driver_kwargs, build_connection_string, _normalize_and_validate_auth, + apply_auth_adapter, provider_for_dialect, +) + + +@dataclass +class _Spec: + provider_type: object + supported_auth: tuple + name: str = "stub" + + +class _Stub: + def __init__(self, pt, sa, base, url=None): + self.__spec__ = _Spec(pt, sa) + self._base, self._url = base, url or UrlParts(scheme="stub", host="h", port=1, database="db") + @property + def backend(self): return self.__spec__.name + def emit(self, target): + assert target is self.__spec__.provider_type + return dict(self._base) + def to_url_parts(self): return self._url + + +def test_noauth_short_circuits(): + assert build_driver_kwargs(_Stub(P.SQLITE, (NoAuthProfile,), {"database": ":memory:"}), None) == {"database": ":memory:"} + + +def test_password_dispatch(): + out = build_driver_kwargs(_Stub(P.POSTGRESQL, (PasswordAuthProfile, NoAuthProfile), {"host": "h"}), + PasswordAuthProfile(USERNAME="u", PASSWORD="p")) + assert out == {"host": "h", "user": "u", "password": "p"} + + +def test_unsupported_auth_valueerror(): + with pytest.raises(ValueError, match="does not support auth"): + build_driver_kwargs(_Stub(P.SQLITE, (NoAuthProfile,), {}), PasswordAuthProfile(USERNAME="u", PASSWORD="p")) + + +def test_supported_but_no_adapter_fails_closed(): + with pytest.raises(ValueError, match="no auth adapter"): + build_driver_kwargs(_Stub(P.POSTGRESQL, (WindowsAuthProfile,), {"host": "h"}), WindowsAuthProfile(USERNAME="u")) + + +def test_none_normalizes_when_supported(): + assert isinstance(_normalize_and_validate_auth(_Stub(P.SQLITE, (NoAuthProfile,), {}), None), NoAuthProfile) + + +def test_none_rejected_when_noauth_unsupported(): + with pytest.raises(ValueError, match="does not support auth"): + _normalize_and_validate_auth(_Stub(P.MYSQL, (PasswordAuthProfile,), {}), None) + + +def test_apply_auth_adapter_non_profile(): + out = apply_auth_adapter(P.POSTGRESQL, {"host": "h"}, PasswordAuthProfile(USERNAME="u", PASSWORD="p")) + assert out == {"host": "h", "user": "u", "password": "p"} + assert apply_auth_adapter(P.POSTGRESQL, {"host": "h"}, None) == {"host": "h"} + + +def test_provider_for_dialect(): + assert provider_for_dialect("postgres") is P.POSTGRESQL + + +def test_url_password(): + s = _Stub(P.POSTGRESQL, (PasswordAuthProfile,), {}, url=UrlParts(scheme="postgresql", host="db", port=5432, database="app")) + assert build_connection_string(s, PasswordAuthProfile(USERNAME="u", PASSWORD="p@s")) == "postgresql://u:p%40s@db:5432/app" + + +def test_url_token_authority_less(): + s = _Stub(P.MOTHERDUCK, (TokenAuthProfile,), {}, url=UrlParts(scheme="md", database="mydb")) + assert build_connection_string(s, TokenAuthProfile(TOKEN="T")) == "md:mydb?motherduck_token=T" + + +@pytest.mark.parametrize("auth", [WindowsAuthProfile(USERNAME="u"), TokenAuthProfile(TOKEN="T")]) +def test_url_unsupported_auth_not_implemented(auth): + s = _Stub(P.POSTGRESQL, (type(auth),), {}, url=UrlParts(scheme="postgresql", host="db")) + with pytest.raises(NotImplementedError): + build_connection_string(s, auth) +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `hatch run test:test-target tests/test_unit/core/factories/test_connection_factory.py -q` +Expected: FAIL — module missing. + +- [ ] **Step 3: Create `core/factories/__init__.py`** +```python +"""Factories that compose backend config + auth into runtime kwargs.""" +``` + +- [ ] **Step 4: Implement `connection_factory.py`** +```python +"""ConnectionFactory: compose BackendProfile config + AuthProfile creds.""" +from __future__ import annotations +import typing as t +from urllib.parse import quote + +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile, TokenAuthProfile, AuthProfile +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings.profile import UrlParts +from mountainash_data.core.settings.adapters.registry import auth_adapter + + +def _iter_specs() -> t.Iterator[t.Any]: + """Registered BackendSpecs, regardless of whether the registry stores + specs or profile classes.""" + from mountainash_data.core.settings.registry import REGISTRY + for v in REGISTRY.values(): + yield v.__spec__ if hasattr(v, "__spec__") else v + + +def provider_for_dialect(dialect: str) -> t.Any: + for spec in _iter_specs(): + if getattr(spec, "ibis_dialect", None) == dialect: + return spec.provider_type + raise KeyError(f"no provider_type for ibis dialect {dialect!r}") + + +def provider_for_scheme(scheme: str) -> t.Any: + norm = scheme.rstrip(":/") + for spec in _iter_specs(): + s = getattr(spec, "connection_string_scheme", None) + if s and s.rstrip(":/") == norm: + return spec.provider_type + raise KeyError(f"no provider_type for URL scheme {scheme!r}") + + +def _normalize_and_validate_auth(profile: t.Any, auth_profile: AuthProfile | None) -> AuthProfile: + auth = NoAuthProfile() if auth_profile is None else auth_profile + if not isinstance(auth, tuple(profile.__spec__.supported_auth)): + raise ValueError(f"{profile.backend} does not support auth: {type(auth).__name__}") + return auth + + +def apply_auth_adapter(provider_type: t.Any, base: dict, auth_profile: AuthProfile | None) -> dict: + """Apply auth WITHOUT a profile (ibis dialect / URL paths). No supported_auth gate.""" + if auth_profile is None or isinstance(auth_profile, NoAuthProfile): + return base + fn = auth_adapter(provider_type, type(auth_profile)) + if fn is None: + raise ValueError(f"{provider_type}: no auth adapter for {type(auth_profile).__name__}") + return fn(auth_profile, base) + + +def build_driver_kwargs(profile: t.Any, auth_profile: AuthProfile | None = None) -> dict: + auth = _normalize_and_validate_auth(profile, auth_profile) + target = profile.__spec__.provider_type + base = profile.emit(target) + if isinstance(auth, NoAuthProfile): + return base + return apply_auth_adapter(target, base, auth) + + +# --- URL appliers (L3 for the URL target) --------------------------------- + +def _url_password(parts: UrlParts, auth: t.Any) -> str: + if parts.host is None: + raise NotImplementedError("password URL form requires a host authority") + user, pw = quote(str(auth.USERNAME), safe=""), quote(auth.PASSWORD.get_secret_value(), safe="") + url = f"{parts.scheme}://{user}:{pw}@{parts.host}" + if parts.port is not None: url += f":{parts.port}" + if parts.database is not None: url += f"/{parts.database}" + return url + + +def _url_noauth(parts: UrlParts) -> str: + url = parts.scheme + "://" + if parts.host is not None: + url += parts.host + (f":{parts.port}" if parts.port is not None else "") + if parts.database is not None: url += f"/{parts.database}" + return url + + +def _url_motherduck_token(parts: UrlParts, auth: t.Any) -> str: + return f"{parts.scheme}:{parts.database}?motherduck_token={auth.TOKEN.get_secret_value()}" + + +_URL_APPLIERS: dict[t.Any, dict[type, t.Callable]] = { + P.MOTHERDUCK: {TokenAuthProfile: _url_motherduck_token}, +} + + +def build_connection_string(profile: t.Any, auth_profile: AuthProfile | None = None) -> str: + auth = _normalize_and_validate_auth(profile, auth_profile) + parts = profile.to_url_parts() # L1 + if isinstance(auth, NoAuthProfile): + return _url_noauth(parts) + if isinstance(auth, PasswordAuthProfile): + return _url_password(parts, auth) # L3 + applier = _URL_APPLIERS.get(profile.__spec__.provider_type, {}).get(type(auth)) + if applier is None: + raise NotImplementedError(f"{profile.backend}: no URL form for {type(auth).__name__}") + return applier(parts, auth) +``` +> Confirm the registry accessor name in `core/settings/registry.py` (`REGISTRY` vs `DATABASES_REGISTRY`) and adjust `_iter_specs`. The `hasattr(v, "__spec__")` branch handles either specs or classes. + +- [ ] **Step 5: Run tests** + +Run: `hatch run test:test-target tests/test_unit/core/factories/test_connection_factory.py -q` +Expected: PASS. + +- [ ] **Step 6: Commit** +```bash +git add core/factories/ tests/test_unit/core/factories/test_connection_factory.py +git commit -m "feat(factories): ConnectionFactory compose, URL appliers, non-profile auth + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 7: Ibis entry points (deferred auth across all three paths) + +**Files:** Modify `backends/ibis/backend.py`; Test `tests/test_unit/backends/ibis/test_backend_auth.py`. + +**Interfaces:** Produces `IbisBackend.connect(self, auth_profile=None)` applying auth on the **settings**, **direct-dialect**, and **URL** paths; fail-closed URL-creds-vs-explicit precedence. + +- [ ] **Step 1: Write failing tests** + +`tests/test_unit/backends/ibis/test_backend_auth.py`: +```python +import pytest +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile +from mountainash_data.backends.ibis.backend import IbisBackend + + +def test_sqlite_dialect_connect_noauth(tmp_path): + be = IbisBackend(dialect="sqlite", database=str(tmp_path / "t.db")).connect(auth_profile=NoAuthProfile()) + assert be is not None + + +def test_dialect_path_applies_password(monkeypatch): + # direct-dialect + explicit auth: auth adapter must run for the dialect's provider. + seen = {} + import mountainash_data.backends.ibis.backend as mod + def fake_apply(pt, base, auth): + seen["pt"], seen["auth"] = pt, auth + return {**base, "user": auth.USERNAME} + monkeypatch.setattr(mod, "apply_auth_adapter", fake_apply) + monkeypatch.setattr(mod, "provider_for_dialect", lambda d: "PG") + IbisBackend(dialect="postgres", host="h", database="db")._resolve_dialect_auth( + PasswordAuthProfile(USERNAME="u", PASSWORD="p") + ) + assert seen["pt"] == "PG" and seen["auth"].USERNAME == "u" + + +def test_url_and_explicit_auth_conflict_raises(): + with pytest.raises(ValueError, match="both"): + IbisBackend("postgresql://u:p@host/db").connect( + auth_profile=PasswordAuthProfile(USERNAME="x", PASSWORD="y")) +``` +> The settings-path end-to-end (`SettingsParameters` → `connect(auth_profile=...)`) is added in Task 9 with the migrated fixtures. + +- [ ] **Step 2: Run to verify it fails** + +Run: `hatch run test:test-target tests/test_unit/backends/ibis/test_backend_auth.py -q` +Expected: FAIL — `connect()` takes no `auth_profile`; `_resolve_dialect_auth` missing. + +- [ ] **Step 3: Defer config build in `_init_from_settings`** + +In `backends/ibis/backend.py`, DELETE the eager `driver_kwargs = obj_settings.to_driver_kwargs()` (line ~242). Store the profile + extras instead: +```python + self.dialect = resolved_dialect + self._spec = DIALECTS[resolved_dialect] + self._url = None + self._profile = obj_settings # settings path + self._extra_config = config # caller **config overrides + self._config = None + self._conn = None +``` +In the direct-dialect path (`_init_from_dialect`), set `self._profile = None`, `self._url = None`, `self._dialect_config = config`, `self._config = None`. In the URL path set `self._profile = None`, keep `self._url = `, `self._config = None`. + +- [ ] **Step 4: Add imports + helpers + thread `connect`** +```python +from mountainash_data.core.factories.connection_factory import ( + build_driver_kwargs, apply_auth_adapter, provider_for_dialect, provider_for_scheme, +) +from mountainash_auth_client import PasswordAuthProfile +from urllib.parse import urlsplit, urlunsplit, unquote + + +def connect(self, auth_profile=None): + if self._conn is not None: + return self + if self._profile is not None: # settings path + cfg = build_driver_kwargs(self._profile, auth_profile) + cfg.update(self._extra_config) + self._config = cfg + elif self._url is not None: # URL path + self._config, self._url = self._resolve_url_auth(self._url, auth_profile) + else: # direct-dialect path + self._config = self._resolve_dialect_auth(auth_profile) + # ...existing connection_builder / ibis.connect(self._url, **self._config) logic... + return self + + +def _resolve_dialect_auth(self, auth_profile): + base = dict(self._dialect_config) + if auth_profile is None: + return base + provider = provider_for_dialect(self.dialect) + return apply_auth_adapter(provider, base, auth_profile) + + +def _resolve_url_auth(self, url, auth_profile): + parts = urlsplit(url) + has_url_creds = bool(parts.username) + if has_url_creds and auth_profile is not None: + raise ValueError("both URL credentials and an explicit auth_profile given") + config: dict = {} + clean = url + if has_url_creds: + netloc = parts.hostname or "" + if parts.port: netloc += f":{parts.port}" + clean = urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + auth_profile = PasswordAuthProfile( + USERNAME=unquote(parts.username), + PASSWORD=unquote(parts.password) if parts.password else "", + ) + if auth_profile is not None: + provider = provider_for_scheme(parts.scheme) + config = apply_auth_adapter(provider, config, auth_profile) + return config, clean +``` +> The existing `connect` body that reads `self._config`/`self._url`/`self._spec.connection_builder` runs UNCHANGED after `self._config` is set above. Confirm no code path reads `self._config` before `connect()` (it is now `None` until `connect`). + +- [ ] **Step 5: Run tests** + +Run: `hatch run test:test-target tests/test_unit/backends/ibis/test_backend_auth.py -q` +Expected: PASS. + +- [ ] **Step 6: Commit** +```bash +git add backends/ibis/backend.py tests/test_unit/backends/ibis/test_backend_auth.py +git commit -m "feat(ibis): deferred auth across settings/dialect/URL paths + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 8: Iceberg auth threading (testable kwargs helper) + +**Files:** Modify `backends/iceberg/connection.py`; Test `tests/test_unit/backends/iceberg/test_iceberg_auth.py`. + +**Interfaces:** Produces `_build_catalog_kwargs(self, auth_profile, **kwargs) -> dict` (no pyiceberg import — real gate); `connect_default(self, *, auth_profile=None, **kwargs)` and `connect(..., auth_profile=None)` threading auth; precedence **profile-derived < explicit `**kwargs`**. + +- [ ] **Step 1: Write failing test (no live catalog needed)** + +`tests/test_unit/backends/iceberg/test_iceberg_auth.py`: +```python +from types import SimpleNamespace +from unittest.mock import patch +from mountainash_auth_client import TokenAuthProfile +from mountainash_data.backends.iceberg.connection import IcebergConnectionBase # confirm exact name + + +def test_build_catalog_kwargs_threads_auth_and_merges(): + obj_settings = object() + # plain stubs — no property/attribute conflict: get_settings returns obj_settings + params = SimpleNamespace( + settings_class=SimpleNamespace(get_settings=lambda settings_parameters: obj_settings) + ) + conn = IcebergConnectionBase.__new__(IcebergConnectionBase) + conn.db_auth_settings_parameters = params + + auth = TokenAuthProfile(TOKEN="T") + with patch( + "mountainash_data.backends.iceberg.connection.build_driver_kwargs", + return_value={"uri": "http://x", "token": "T", "name": "c"}, + ) as bk: + out = conn._build_catalog_kwargs(auth, warehouse="w") + + bk.assert_called_once_with(obj_settings, auth) # profile + auth_profile threaded + assert out["warehouse"] == "w" # explicit kwargs win + assert out["uri"] == "http://x" +``` +> Confirm the real `IcebergConnectionBase` class/attribute names at implementation time (the `.db_auth_settings_parameters` + `.settings_class.get_settings(...)` shape is from the current `connect_default`); the load-bearing assertions (profile+auth threaded; explicit kwargs win) stay. `build_driver_kwargs` is patched at the name bound INSIDE `connection.py`, not at its definition site. + +- [ ] **Step 2: Run to verify it fails** + +Run: `hatch run test:test-target tests/test_unit/backends/iceberg/test_iceberg_auth.py -q` +Expected: FAIL — `_build_catalog_kwargs` missing. + +- [ ] **Step 3: Extract the kwargs helper + thread auth** + +Replace line ~112's `connection_kwargs = obj_settings.to_driver_kwargs()` path: +```python +from mountainash_data.core.factories.connection_factory import build_driver_kwargs + + +def _build_catalog_kwargs(self, auth_profile=None, **kwargs): + settings_class = self.db_auth_settings_parameters.settings_class + if settings_class is None: + raise ValueError("Settings class is required for the database connection") + obj_settings = settings_class.get_settings(settings_parameters=self.db_auth_settings_parameters) + connection_kwargs = build_driver_kwargs(obj_settings, auth_profile) + connection_kwargs.update(kwargs) # explicit caller kwargs win + return connection_kwargs + + +def connect_default(self, *, auth_profile=None, **kwargs): + if self.catalog_backend is None: + connection_kwargs = self._build_catalog_kwargs(auth_profile, **kwargs) + from pyiceberg.catalog.rest import RestCatalog + self._catalog_backend = RestCatalog(**connection_kwargs) + return self.catalog_backend + + +def connect(self, connection_string=None, connection_kwargs=None, *, auth_profile=None, **kwargs): + if self.catalog_backend is None: + self.connect_default(auth_profile=auth_profile, **(connection_kwargs or {}), **kwargs) + return self.catalog_backend +``` +Document the precedence in both docstrings. + +- [ ] **Step 4: Run test** + +Run: `hatch run test:test-target tests/test_unit/backends/iceberg/test_iceberg_auth.py -q` +Expected: PASS (no pyiceberg needed — `RestCatalog` import is inside `connect_default`, not reached by the helper test). + +- [ ] **Step 5: Commit** +```bash +git add backends/iceberg/connection.py tests/test_unit/backends/iceberg/test_iceberg_auth.py +git commit -m "feat(iceberg): thread auth via testable _build_catalog_kwargs + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 9: Migrate existing tests + consistency goldens + green gate + +**Files:** Modify `tests/fixtures/settings_fixtures.py` + the ~25 test files; Create `tests/test_unit/core/settings/test_supported_auth_consistency.py`, `tests/test_unit/core/factories/test_url_consistency.py`; full-suite gate. + +- [ ] **Step 1: Migrate fixtures** + +`tests/fixtures/settings_fixtures.py`: replace `NoAuth`→`NoAuthProfile`, `SQLiteAuthSettings`→`SQLiteBackendProfile`, `DuckDBAuthSettings`→`DuckDBBackendProfile`. DELETE every `"auth": NoAuth()` from `kwargs={...}` (auth is no longer a profile field). Where a test needs auth, yield `(backend_profile, auth_profile)` pairs. + +- [ ] **Step 2: Migrate per-backend tests (mechanical)** + +For each `tests/test_unit/core/settings/backends/test_.py`: imports → `BackendProfile` + `*AuthProfile`; construction → `BackendProfile(...)` (no `auth=`), UPPERCASE auth kwargs with plain strings (`PasswordAuthProfile(USERNAME="u", PASSWORD="p")` — pydantic wraps secrets); replace `s.to_driver_kwargs()` with `build_driver_kwargs(s, )` from `mountainash_data.core.factories.connection_factory`. + +Worked example — `test_postgresql.py`: +```python +from mountainash_auth_client import PasswordAuthProfile +from mountainash_data.core.settings import PostgreSQLBackendProfile +from mountainash_data.core.factories.connection_factory import build_driver_kwargs + + +def test_postgres_driver_kwargs(): + s = PostgreSQLBackendProfile(HOST="db", DATABASE="app") + out = build_driver_kwargs(s, PasswordAuthProfile(USERNAME="u", PASSWORD="p")) + assert out["host"] == "db" and out["user"] == "u" and out["password"] == "p" +``` + +- [ ] **Step 3: supported_auth ↔ table consistency** + +`tests/test_unit/core/settings/test_supported_auth_consistency.py`: +```python +from mountainash_auth_client import NoAuthProfile +from mountainash_data.core.settings.adapters.registry import auth_adapter +from mountainash_data.core.factories.connection_factory import _iter_specs + + +def test_every_supported_pair_has_an_adapter(): + for spec in _iter_specs(): + for auth_cls in spec.supported_auth: + if auth_cls is NoAuthProfile: + continue + assert auth_adapter(spec.provider_type, auth_cls) is not None, ( + f"{spec.name}: supported {auth_cls.__name__} has no adapter" + ) +``` +> Uses `_iter_specs()` (Task 6) which normalises the registry; this is a structural invariant, NOT a count assertion. + +- [ ] **Step 4: URL applier coverage** + +`tests/test_unit/core/factories/test_url_consistency.py`: +```python +import pytest +from mountainash_auth_client import PasswordAuthProfile, TokenAuthProfile +from mountainash_data.core.settings import PostgreSQLBackendProfile, MotherDuckBackendProfile +from mountainash_data.core.factories.connection_factory import build_connection_string + + +def test_postgres_password_url(): + s = PostgreSQLBackendProfile(HOST="db", PORT=5432, DATABASE="app") + assert build_connection_string(s, PasswordAuthProfile(USERNAME="u", PASSWORD="p@s")) == "postgresql://u:p%40s@db:5432/app" + + +def test_motherduck_token_url(): + assert build_connection_string(MotherDuckBackendProfile(DATABASE="mydb"), TokenAuthProfile(TOKEN="T")) == "md:mydb?motherduck_token=T" + + +def test_snowflake_token_url_not_implemented(): + # snowflake supports TokenAuthProfile for kwargs but has no URL form → fail-closed + from mountainash_data.core.settings import SnowflakeBackendProfile + with pytest.raises(NotImplementedError): + build_connection_string(SnowflakeBackendProfile(ACCOUNT="a"), TokenAuthProfile(TOKEN="T")) +``` + +- [ ] **Step 5: Migrate remaining unit/integration tests** + +`tests/test_integration/test_end_to_end_workflows.py`, `tests/test_unit/backends/ibis/test_backend.py`, `tests/test_unit/core/settings/test_{descriptor,profile,registry}.py`, `tests/test_unit/databases/settings/test_settings_parametrized.py`: swap to new names; move any `auth=` on a profile to the `connect(auth_profile=...)` / `build_driver_kwargs(profile, auth)` call. Add the settings-path ibis auth test deferred from Task 7 (a `SQLiteBackendProfile` via `SettingsParameters` through `connect(auth_profile=NoAuthProfile())`). + +- [ ] **Step 6: Full suite** + +Run: `hatch run test:test` +Expected: PASS (driver-gated tests skip without extras). Root-cause any failure — never silence. If a flat backend's `{user,password}` golden disagrees, STOP and surface (test-integrity). + +- [ ] **Step 7: Type + lint gate** + +Run: `hatch run mypy:check` +Run: `hatch run ruff:check` +Expected: both clean. + +- [ ] **Step 8: Commit** +```bash +git add -A tests/ +git commit -m "test: migrate suite to *BackendProfile + factory; add consistency goldens + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-Review + +**1. Spec coverage** — §3.1 rename → T2; §3.2 decouple → T6,7,8; §3.3 emit + 4-backend shaping → T2,T3; §3.4 MRO dispatch → T4,T5; §3.5 factory fail-closed → T6; §4.3 supported_auth+invariant → T2; §4.4 BackendProfile/UrlParts/to_url_parts → T2; §4.6 two-layer URL → T6; §4.8 deferred auth + URL precedence (all 3 paths) → T7; iceberg → T8; §4.9 deps → T1; §5 TOKEN-only OAuth2 + iceberg token-only → T4,T2; §6 validation/fail-closed/consistency → T6,T9; §7 testing → T2–T9; §10 deferred → out of scope, preserved. ✓ + +**2. Placeholder scan** — T7/T8 settings-path/ctor specifics are confirmed at implementation against real names; the load-bearing assertions are concrete. Two VERIFY callouts (registry accessor in T6; flat `{user,password}` goldens in T9) are test-integrity-gated (surface, don't guess), not placeholders. + +**3. Type consistency** — `*BackendProfile`, `build_driver_kwargs(profile, auth_profile=None)`, `apply_auth_adapter(provider_type, base, auth_profile)`, `auth_adapter(provider_type, auth_class)`, `UrlParts(...)`, compose `(profile, base)→dict`, auth `(auth, base)→dict`, `provider_for_dialect/scheme` used identically across tasks. `UrlParts` defined once in `core/settings/profile.py`, imported by the factory. + +--- + +## Execution Handoff + +Verify-at-implementation points (all test-integrity-gated): (1) registry accessor name (`REGISTRY`/`DATABASES_REGISTRY`) in `_iter_specs` (T6); (2) flat `{user,password}` goldens (T9); (3) real `IcebergConnectionBase` ctor/attr names (T8); (4) `ProfileSpec.__post_init__` presence (T2). PR-time flag: `core/factories/` also exists on the `settings-registry` worktree branch — watch for merge conflict. diff --git a/docs/superpowers/specs/2026-06-27-dialect-aware-add-columns-design.md b/docs/superpowers/specs/2026-06-27-dialect-aware-add-columns-design.md new file mode 100644 index 0000000..cdcfa6e --- /dev/null +++ b/docs/superpowers/specs/2026-06-27-dialect-aware-add-columns-design.md @@ -0,0 +1,308 @@ +# Dialect-Aware Schema Evolution (`add_columns`) + +> **Date:** 2026-06-27 +> **Status:** Draft +> **Backlog ref:** `mountainash-central/01.principles/mountainash-data/f.backlog/dialect-aware-schema-evolution.md` +> **Sibling:** `mountainash-central/01.principles/mountainash-data/f.backlog/generic-default-dialect-operations.md` (applies this pattern to `upsert`/`rename_table`) +> **Builds on:** `docs/superpowers/specs/2026-04-27-settings-aware-ibis-backend-design.md` + +## Goal + +Add a dialect-agnostic `IbisBackend.add_columns(name, source)` operation that +performs **additive** schema evolution — adding columns present in an incoming +frame (or an explicit `{name: dtype}` map) but missing from a target table. +Consumers must never hand-roll `ALTER TABLE … ADD COLUMN` DDL or maintain +their own polars→backend type maps. + +This removes the last non-portable seam in mountainash-wearables' +`WearableStore`/`BronzeStore` (`_evolve_schema` + `_POLARS_TO_DUCKDB` + +`_cast_null_columns`), which today is DuckDB-only DDL bypassing Ibis. + +## Investigation Corrections (read before designing) + +Two assumptions in the backlog item do **not** hold in mountainash-data and +shaped this design: + +1. **There is no "type bridge" in `create_table` to reuse.** + `IbisBackend.create_table` (`backend.py:321`) is a pure passthrough to + `conn._ibis_conn.create_table(...)`; Ibis infers all column types natively. + "Reuse whatever type bridge `create_table` applies" therefore means **let + Ibis render the types** — specifically via the connection's own + `compiler.type_mapper`, which is exactly what Ibis uses to emit `CREATE + TABLE` DDL. This guarantees an evolved column and a freshly-created column + get **byte-identical** types (verified — see Parity Invariant). + +2. **`_cast_null_columns` is a consumer convention, not an internal one.** + It exists only in wearables. Absorbing "null-typed column → dialect string + type" into `add_columns` is a *new* hoisted behaviour, implemented against + the Ibis `null` dtype rather than against polars. + +## API Surface + +```python +def add_columns( + self, + name: str, + source: t.Any, # frame OR Mapping[str, dtype] + *, + database: str | None = None, +) -> IbisBackend: # fluent — returns self +``` + +`source` is one of: + +- **A frame** — any object `ibis.memtable(...)` accepts (polars/pandas/pyarrow). + Candidate column types are inferred via `ibis.memtable(source).schema()`, + the same inference `create_table` relies on. +- **A `Mapping[str, dtype]`** — explicit column→type. Each value may be an + `ibis.DataType`, an ibis type **string** (`"float64"`), or a + `MountainashDtype` (resolved through the canonical ibis bridge — see Source + Normalization). + +```python +# Infer from the frame, then upsert — the consumer pattern. +backend.add_columns("readings", df) # idempotent, additive +backend.upsert("readings", df, conflict_columns=keys) + +# Explicit types. +from mountainash.core.dtypes.canonical import MountainashDtype +backend.add_columns("readings", {"hrv": MountainashDtype.FP64}) # NB: FP64 +backend.add_columns("readings", {"hrv": "float64"}) # equivalent +``` + +> Note: the canonical member is `MountainashDtype.FP64`, **not** `FLOAT64` as +> the backlog example wrote. There is no `FLOAT64` member. + +## Semantics + +- **Additive only.** Adds columns. Never drops, renames, or re-types existing + columns. Out of scope by design (matches the consumer need: + passthrough-column accretion). +- **Idempotent / introspective.** Missing columns are computed against the + live table schema (`conn._ibis_conn.table(name).schema().names`). A call + that adds nothing is a no-op. Safe to call unconditionally before every + write. (Verified: a repeated call adds `[]`.) +- **Type parity with `create_table`.** Types render through the connection's + own `compiler.type_mapper.to_string(dtype)` — the identical mapper Ibis uses + for `CREATE TABLE`. An evolved column is typed exactly as a freshly-created + one would be. +- **Null-typed columns → dialect string.** A candidate column whose inferred + dtype is Ibis `null` (an all-null incoming column) is coerced to + `ibis.dtype("string")` before rendering, so it is creatable on every + dialect. Replaces the wearables `_cast_null_columns` hack. +- **One column per statement.** SQLite permits only a single `ADD COLUMN` per + `ALTER TABLE`; the implementation issues one statement per new column for + universal portability. + +## Design + +### Dispatch shape — generic default with override seam + +Unlike `upsert`/`create_index` (hook-or-`NotImplementedError`), +`add_columns` is a **uniform-SQL** operation: `ALTER TABLE … ADD COLUMN …` is +standard across the registry; only type rendering and identifier quoting vary, +and both are already encapsulated by the connection's compiler. So the default +is a single generic implementation that **works on every SQL dialect**, with +an optional per-dialect override for genuine exceptions. + +```python +# backend.py — thin method, mirrors the existing hook-dispatch wiring +def add_columns(self, name, source, *, database=None): + conn = self._require_connected() + hook = self._spec.add_columns_hook + if hook is not None: + hook(conn._ibis_conn, name, source, database=database) # override wins + else: + _generic_add_columns(conn._ibis_conn, name, source, database=database) + return self +``` + +```python +# _registry.py — new optional field on DialectSpec (default None) +add_columns_hook: t.Optional[AddColumnsHook] = None +``` + +No dialect registers a hook initially; the generic path covers all of them. +The field exists so a dialect that genuinely cannot `ADD COLUMN`, or needs a +quirk, can override later — consistent with the established extensibility +pattern. + +### Generic implementation (`operations.py`) + +Verified end-to-end on duckdb and sqlite in the test env: + +```python +from sqlglot import exp + +def _generic_add_columns(ibis_conn, table_name, source, *, database=None): + candidate = _normalize_to_schema(source) # -> ibis.Schema + existing = set(ibis_conn.table(table_name, database=database).schema().names) + tm = ibis_conn.compiler.type_mapper # exact create_table mapper + dialect = ibis_conn.compiler.dialect # sqlglot dialect for quoting + + def _quote(name): # quote each part separately + return exp.to_identifier(name, quoted=True).sql(dialect=dialect) + + table_parts = [database, table_name] if database else [table_name] + ident_t = ".".join(_quote(p) for p in table_parts) # never quote "db.t" as one + + for col_name, dtype in candidate.items(): + if col_name in existing: + continue + if dtype.is_null(): # all-null col -> string + dtype = ibis.dtype("string") + type_sql = tm.to_string(dtype) + ibis_conn.raw_sql( + f"ALTER TABLE {ident_t} ADD COLUMN {_quote(col_name)} {type_sql}" + ) +``` + +Rendering primitives are read off the **live connection** — no dialect name→ +class lookup, no hardcoded type knowledge. `compiler.type_mapper` and +`compiler.dialect` are present on every Ibis SQL backend (verified on the +test env's Ibis; confirm against the pinned Ibis during implementation). + +### Source normalization + +```python +def _normalize_to_schema(source) -> ibis.Schema: + if isinstance(source, t.Mapping): + return ibis.schema({k: _coerce_dtype(v) for k, v in source.items()}) + return ibis.memtable(source).schema() # frame inference + +def _coerce_dtype(v) -> ibis.DataType: + if isinstance(v, ibis.DataType): + return v + if isinstance(v, MountainashDtype): + from mountainash.core.dtypes import target_ibis + return ibis.dtype(target_ibis.SCHEMA_TYPES[v]) # canonical bridge + return ibis.dtype(v) # str or polars/pyarrow dtype +``` + +`target_ibis.SCHEMA_TYPES` maps each `MountainashDtype` to an ibis-castable +type string (`FP64`→`"float64"`, `U8`→`"uint8"`, …). **Limitation:** +parametric members (`LIST`→`array`, `STRUCT`) are not expressible via the bare +enum (they need element types) and will raise on coercion; use an explicit +`ibis.DataType` or the frame form for nested columns. + +## Parity Invariant (verified) + +A freshly-`create_table`d column and an `add_columns`-evolved column produce +identical schemas because both flow through the same `type_mapper`. Confirmed +even for an edge type — `uint8` on SQLite, which has no native unsigned type: + +``` +fresh-created uint8 : unknown(DataType(this=DType.USERDEFINED, kind=utinyint)) +evolved uint8 : unknown(DataType(this=DType.USERDEFINED, kind=utinyint)) +PARITY HOLDS : True +``` + +## Known Limitations + +- **Unsigned integers on dialects without them** (SQLite affinity, PostgreSQL + has no unsigned types) render to engine-specific spellings that may not + round-trip cleanly. This is an upstream Ibis behaviour shared by + `create_table` — parity holds, so `add_columns` introduces no new + divergence. Document, don't work around. +- **Parametric types via bare `MountainashDtype`** (LIST/STRUCT) are + unsupported in the explicit-map form; supply an `ibis.DataType` or use the + frame form. +- **Additive only** — re-typing/dropping/renaming are explicitly out of scope. + +## Files Changed + +| File | Change | +|------|--------| +| `src/mountainash_data/backends/ibis/operations.py` | `_generic_add_columns`, `_normalize_to_schema`, `_coerce_dtype` | +| `src/mountainash_data/backends/ibis/backend.py` | `IbisBackend.add_columns` thin method (hook dispatch + generic fallback) | +| `src/mountainash_data/backends/ibis/dialects/_registry.py` | `add_columns_hook` optional field on `DialectSpec`; `AddColumnsHook` type alias | +| `tests/test_unit/backends/ibis/test_backend.py` | add_columns tests (see Testing) | + +## Files NOT Changed + +- `DialectSpec` per-dialect entries — no hooks registered; generic path covers all. +- `create_table` / `insert` / `upsert` — untouched. +- `core/protocol.py` — `add_columns` is an `IbisBackend` capability, not part + of the minimal `Connection` protocol (consistent with `upsert`/`create_index`). +- No new files. + +## Testing + +All tests use in-memory SQLite and DuckDB (no external deps), matching the +existing suite. Cases mirror the verified prototype: + +```python +def test_add_columns_infers_from_frame_duckdb(): + with IbisBackend(dialect="duckdb", database=":memory:") as be: + be.create_table("t", pl.DataFrame({"id": [1], "name": ["a"]})) + df = pl.DataFrame({"id": [1], "name": ["a"], "score": [1.5]}) + be.add_columns("t", df) + cols = {c.name: c.type_name for c in be.inspect_table("t").columns} + assert "score" in cols + +def test_add_columns_is_idempotent(): + with IbisBackend(dialect="sqlite", database=":memory:") as be: + be.create_table("t", {"id": [1]}) + be.add_columns("t", {"x": "float64"}) + be.add_columns("t", {"x": "float64"}) # no-op, no error + names = [c.name for c in be.inspect_table("t").columns] + assert names.count("x") == 1 + +def test_add_columns_null_column_becomes_string(): + with IbisBackend(dialect="duckdb", database=":memory:") as be: + be.create_table("t", {"id": [1]}) + df = pl.DataFrame({"id": [1], "note": pl.Series([None], dtype=pl.Null)}) + be.add_columns("t", df) + cols = {c.name: c.type_name for c in be.inspect_table("t").columns} + assert cols["note"] == "string" + +def test_add_columns_explicit_mountainash_dtype(): + from mountainash.core.dtypes.canonical import MountainashDtype + with IbisBackend(dialect="duckdb", database=":memory:") as be: + be.create_table("t", {"id": [1]}) + be.add_columns("t", {"hrv": MountainashDtype.FP64}) + cols = {c.name: c.type_name for c in be.inspect_table("t").columns} + assert cols["hrv"] == "float64" + +def test_add_columns_create_evolve_parity_sqlite(): + """Evolved column types match freshly-created ones (the core invariant).""" + # create uint8 fresh vs evolve uint8; assert identical schema repr + +def test_add_columns_quotes_identifiers(): + """A column name needing quoting (space/keyword) is added correctly.""" + with IbisBackend(dialect="duckdb", database=":memory:") as be: + be.create_table("t", {"id": [1]}) + be.add_columns("t", {"new col": "float64"}) +``` + +## Consumer Migration (mountainash-wearables, after ship) + +- `WearableStore._evolve_schema` + `_POLARS_TO_DUCKDB` → **delete**; the + `upsert` path becomes `self._backend.add_columns(table, df)` then + `self._backend.upsert(...)`. +- `WearableStore._cast_null_columns` / `BronzeStore._cast_null_columns` → + **delete**; null coercion now lives in `add_columns`. (Confirm no remaining + caller relies on the frame itself being cast before `create_table` — if + `full_replace`/initial `create_table` still need it, keep a thin local cast + only there, or rely on Ibis inference.) +- `BronzeStore` evolution → identical replacement. + +> Caveat carried from the sibling backlog item: `add_columns` makes +> *evolution* portable, but wearables also calls `upsert`, which currently has +> a hook only for the duckdb/sqlite family. Swapping wearables to PostgreSQL +> needs **both** this item and the `upsert` generalization. + +## Commit Strategy + +Single feature branch targeting `develop`. Suggested commits: + +1. `feat(ibis): add dialect-agnostic add_columns with generic-default dispatch` + — operations + backend method + `DialectSpec.add_columns_hook` field + tests. +2. `chore(hatch): drop deprecated mountainash-utils-ssh from test env` — the + stale path dependency removed to unblock the test env (see note below). + +> **Env note (out-of-band):** the `[envs.test]` dependency list referenced +> `../mountainash-utils-ssh`, which has been moved to `deprecated/`. It is only +> a commented-out import in `core/connection.py` and not a runtime dependency, +> so it was removed from the test env to allow a clean rebuild. Flag for the +> maintainer in case other envs (`dev`, `tower`) need the same cleanup. diff --git a/docs/superpowers/specs/2026-06-28-auth-client-migration-design.md b/docs/superpowers/specs/2026-06-28-auth-client-migration-design.md new file mode 100644 index 0000000..c050fa4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-auth-client-migration-design.md @@ -0,0 +1,686 @@ +# Design Spec: Migrate mountainash-data to mountainash-auth-client + +**Date:** 2026-06-28 +**Status:** Draft — for review +**Author:** Nathaniel Ramm (with Claude) +**Supersedes:** `2026-06-27-auth-client-migration-design.md` (v1–v4). That draft +routed auth translation through `Profile.register_adapter` (registering +data's adapters onto auth-client's classes). This rewrite drops that entirely: +**data owns its auth translation in its own code**, mirroring how +`mountainash-wearables` reads credentials directly. No dependency on the +settings `register_adapter` primitive. + +--- + +## 1. Context & Problem + +mountainash-data's settings layer still imports `mountainash_settings.auth`, which +was **deleted upstream** when auth was extracted into the standalone +`mountainash-auth-client` package (settings commit `3d0f4a4`). Against the live +`mountainash-settings` 26.5.0 the package is **broken**: the whole test suite fails +at collection — `conftest` → settings fixtures → `core/settings/__init__.py:21` → +`from mountainash_settings.auth import …` → `ModuleNotFoundError`. Top-level +`import mountainash_data` only survives because `__init__` does not eagerly load the +settings layer. + +This is **not a rename**. Three pieces of upstream machinery mountainash-data leaned +on were also removed: + +| Removed upstream | mountainash-data dependency | Failure | +|---|---|---| +| `auth_modes` field on `ProfileSpec` (settings `2d72318`) | all 20 backends call `BackendSpec(auth_modes=[…])` | `TypeError` at import — frozen dataclass, unknown kwarg | +| `_auth_kwargs()` on `Profile` (settings `297b587`) | the base profile's `to_driver_kwargs()` + `adapters/mysql.py` call it | `AttributeError` at runtime | +| auto-installed `.auth` discriminated-union field (driven by `auth_modes`) | per-backend adapters + `to_connection_string()` read `self.auth` | field no longer exists | +| `mountainash_settings.auth` module | `__init__.py`, the per-backend settings files, 9 adapters, the `core/settings/auth/` shim, ~28 tests | `ModuleNotFoundError` | + +### The new auth model (`mountainash-auth-client`) + +auth-client replaces the old pydantic `*Auth` classes with `*AuthProfile` +classes (subclasses of `mountainash_settings.Profile`): + +- Names: `PasswordAuth` → `PasswordAuthProfile`, `NoAuth` → `NoAuthProfile`, etc. + **No backward-compat aliases, no `AuthSpec` base** — an `AuthProfile` union type + is exported instead. +- Fields are **UPPERCASE** `ParameterSpec` names: `auth.username` → `auth.USERNAME`, + `auth.password` → `auth.PASSWORD`. Secret fields remain pydantic `SecretStr` + (`.get_secret_value()` still works); path fields are `Path | None`. +- The auth profiles ship `emit()`/`__adapters__` adapters for auth-client's own + SDK families (`HTTP`/`BOTO`/`PARAMIKO`). **These are reference implementations** + — the shape a client copies, not a surface a client extends. mountainash-data's + targets are ibis DB drivers, outside those families, so data does **not** use the + auth profiles' `emit()`; it reads their fields directly (§3.4). + +### Project constraints + +mountainash-data is **pre-release with zero downstream consumers**. A **clean +break** is required; the goal is the best architecture for this infrastructure +package, **not** backward compatibility. No deprecation aliases, no compat shims. + +--- + +## 2. Goals & Non-Goals + +### Goals +1. Unbreak the package against `mountainash-settings` 26.5.0 + `mountainash-auth-client`. +2. Adopt `mountainash-transport`'s **three-layer separation** — declarative config + profile, runtime connection, composing factory — with auth **decoupled** from the + config profile and passed alongside it at connect time. +3. **Own the auth→driver-kwargs translation in mountainash-data** (the + `mountainash-wearables` model: read the auth profile's fields directly). Driver + imports (`trino.auth`, `google.oauth2`) stay in data. Nothing is registered onto + auth-client's classes; data depends on no settings extension primitive. +4. Replace the deleted `auth_modes` / `_auth_kwargs` / `.auth`-field machinery. +5. Rename the misnamed `*AuthSettings` classes to `*BackendProfile` (base + `ConnectionProfile` → `BackendProfile`), reserving **"Connection" for the runtime + layer** (§3.1). +6. Make `mountainash-auth-client` a first-class core dependency. +7. All tests green under `hatch run test:test`; `mypy:check` + `ruff:check` clean. + +### Non-Goals +- Interactive OAuth **acquisition**/persistence (`OAuth2TokenManager`, + `PersistableAuthProfile`, `token_store`). Deferred — §10 Backlog. +- Reworking the Ibis `DialectSpec` registry, the inspection model, or the iceberg + catalog registry beyond the auth threading. +- Adding new backends or auth types. +- Any use of `Profile.register_adapter` / cross-package adapter registration. + +--- + +## 3. Architecture + +### 3.1 Three layers — and "Connection" reserved for the runtime + +mountainash-data mirrors `mountainash-transport`'s three roles. The config-layer +classes are named `*BackendProfile` so "Connection" belongs exclusively to the +runtime handles: + +| Role | transport | mountainash-data | +|---|---|---| +| **config profile** — declarative, owns `emit()` for its own config | `settings/storage/profiles/*StorageProfile` | `core/settings/*BackendProfile` | +| **runtime connection** — consumes a finished kwargs dict, opens the handle | `connections/*Connection` | `backends/ibis` (`IbisBackend`/`IbisConnection`), `backends/iceberg` (`IcebergConnection`) | +| **composing factory** — bridges the two, layers auth onto config | `connections/__init__.py:create_connection` | `core/factories/ConnectionFactory` | + +- Base class `ConnectionProfile` → `BackendProfile`. +- 20 leaves `*AuthSettings` → `*BackendProfile` (e.g. `PostgreSQLBackendProfile`). +- Runtime handles keep `IbisConnection` / `IcebergConnection` / `BaseDBConnection`. + +### 3.2 Decouple auth from the backend profile + +A `*BackendProfile` carries **only backend config** (host/port/database/ +warehouse/role/…). Auth is a **separate, orthogonal** `AuthProfile | None` passed +alongside it at connect time — mirroring transport's +`create_connection(storage_profile, auth_profile)`, and reflecting reality: the same +server config is reusable with different credentials. + +```python +backend = IbisBackend(dialect="postgres", host="db", database="app") +conn = backend.connect( + auth_profile=PasswordAuthProfile(USERNAME="app", PASSWORD="s3cret"), +) +``` + +### 3.3 Config emission — `BackendProfile.emit()` (transport-style, data-owned) + +The config side uses the `emit()` pipeline exactly as transport's `StorageProfile` +does — and data owns these classes, so the adapters are **class-body literals**, the +copy-on-write-safe idiom (no `register_adapter`): + +- **Flat backends** (17 of 20): `ParameterSpec.driver_key` renames alone. The package + already carries ~143 `driver_key` annotations; `profile.emit(target)` runs them via + `_default_kwargs`. +- **Non-flat backends** (3): a class-literal `__adapters__` compose adapter builds the + nested/combined config the driver wants — the direct analogue of transport's S3 + `botocore.Config`, SFTP `_post_connect`, HTTP `httpx.Timeout`: + + | Backend | Non-flat connection shaping | Transport precedent | + |---|---|---| + | mysql | nested `ssl={…}` from the 5 `SSL_*` fields | `HTTPStorageProfile` → `httpx.Timeout(...)` | + | mssql | fold `HOST` + `INSTANCE_NAME` → `host\instance`; encryption flags | `SFTPStorageProfile` → `_post_connect` | + | snowflake | `session_parameters={…}` from `QUERY_TAG`/`TIMEZONE` | `S3StorageProfile` → `botocore.Config(...)` | + +The emit **target key is the backend's `provider_type`** (`CONST_DB_PROVIDER_TYPE`, +already on every spec) — no new target enum. A shaping backend keys its literal under +its own `provider_type`: + +```python +@register +class MySQLBackendProfile(BackendProfile): + __spec__ = MYSQL_SPEC # provider_type=MYSQL + __adapters__ = {CONST_DB_PROVIDER_TYPE.MYSQL: _mysql.ssl_compose} +``` + +`base = profile.emit(profile.__spec__.provider_type)` is then uniform across all 20: +flat backends just rename; the 3 shaping backends additionally run their compose +adapter (which receives the already-renamed dict as its second arg). The compose +functions live in `core/settings/adapters/.py`. + +**Compose-adapter invariant (no double-render).** A compose adapter only **adds** the +nested/combined keys that flat `driver_key` renames cannot express; it never +re-derives or overwrites a key `driver_key` already produced. This is the convergence +backlog's smell #2 (transport's S3 path sets a field via `driver_key` then overwrites +it in the compose hook) — mountainash-data must not inherit it. Concretely, **any +field a compose adapter folds into a combined/nested key carries no conflicting flat +`driver_key`**, so exactly one renderer owns each output key: + +| Backend | Adapter adds | Source fields (must NOT also emit flat) | +|---|---|---| +| mysql | `ssl={…}` nested dict | the 5 `SSL_*` fields → no flat `driver_key`; they exist only to feed `ssl` | +| mssql | `host` as `HOST\INSTANCE_NAME` | `INSTANCE_NAME` → no flat `driver_key`; `HOST` keeps its `driver_key` and the adapter **rewrites that one key** (the sole exception — documented, asserted) | +| snowflake | `session_parameters={…}` | `QUERY_TAG`/`TIMEZONE` → no flat `driver_key`; they feed `session_parameters` only | + +The mssql `host` rewrite is the single sanctioned overwrite (a host *can't* be +expressed as a flat rename when it folds a second field). The invariant is enforced +**mechanically** by a key-delta assertion (§7), not just by checking for stray keys: +diff the pre-compose renamed dict against the post-compose dict and assert the only +differences are the sanctioned ones — + +- **mysql / snowflake:** *pure additions* — every pre-compose key is byte-identical + afterward, and exactly the combined key (`ssl` / `session_parameters`) is added; the + folded source fields (`SSL_*`, `QUERY_TAG`/`TIMEZONE`) were never flat to begin with. +- **mssql:** the **only** changed/added key is `host`; `instance_name` must be absent; + every other key is byte-identical. + +Any other key whose value changes between the two dicts is a test failure — this +catches an accidental overwrite at the source, not just a leaked key. + +### 3.4 Auth translation — data-owned, in the factory (the wearables model) + +auth-client can't ship adapters for ibis drivers (they'd need `import trino.auth`), +and its HTTP/BOTO/PARAMIKO adapters are **reference implementations to copy, not an +extension surface**. So mountainash-data owns its auth→kwargs translation outright, +the way `mountainash-wearables` reads `profile.PASSWORD.get_secret_value()` directly +in its connections. **Data does not call `auth_profile.emit()` and registers nothing +onto auth-client's classes.** + +The translation lives behind a **data-owned dispatch table** keyed by +`(provider_type, auth_class)` — declarative dispatch with no `isinstance` ladders, +entirely inside mountainash-data: + +```python +# core/settings/adapters/registry.py (data-owned; NOT auth-client) +from mountainash_auth_client import PasswordAuthProfile, JWTAuthProfile, \ + KerberosAuthProfile, ServiceAccountAuthProfile, IAMAuthProfile, TokenAuthProfile, \ + OAuth2AuthProfile, CertificateAuthProfile, WindowsAuthProfile, AzureADAuthProfile +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from . import sql as _sql, trino as _trino, snowflake as _snow, bigquery as _bq, \ + databricks as _dbx, mssql as _mssql, redshift as _rs, pyiceberg_rest as _ice + +# (provider_type, auth_class) -> (auth_profile, base) -> dict +_AUTH_ADAPTERS: dict[tuple, Callable] = { + (P.TRINO, PasswordAuthProfile): _trino.password, + (P.TRINO, JWTAuthProfile): _trino.jwt, + (P.TRINO, KerberosAuthProfile): _trino.kerberos, + (P.SNOWFLAKE, PasswordAuthProfile): _snow.password, + (P.SNOWFLAKE, TokenAuthProfile): _snow.token, + (P.SNOWFLAKE, OAuth2AuthProfile): _snow.oauth2, + (P.SNOWFLAKE, CertificateAuthProfile): _snow.certificate, + (P.BIGQUERY, ServiceAccountAuthProfile): _bq.service_account, + (P.DATABRICKS, TokenAuthProfile): _dbx.token, + (P.DATABRICKS, PasswordAuthProfile): _dbx.password, + (P.MSSQL, PasswordAuthProfile): _mssql.password, + (P.MSSQL, WindowsAuthProfile): _mssql.windows, + (P.MSSQL, AzureADAuthProfile): _mssql.azure_ad, + (P.REDSHIFT, PasswordAuthProfile): _rs.password, + (P.REDSHIFT, IAMAuthProfile): _rs.iam, + (P.PYICEBERG_REST, TokenAuthProfile): _ice.token, +} +# Flat user/password backends share one adapter: +for _p in (P.POSTGRES, P.MYSQL, P.CLICKHOUSE, P.MATERIALIZE, P.RISINGWAVE, + P.DRUID, P.SINGLESTOREDB, P.IMPALA, P.EXASOL): + _AUTH_ADAPTERS[(_p, PasswordAuthProfile)] = _sql.userpass + +def auth_adapter(provider_type, auth_class): + # Most-specific-first dispatch over the MRO (functools.singledispatch semantics): + # the nearest registered base in auth_class.__mro__ wins. This makes dispatch agree + # with §6's isinstance validation (a subclass of an allowed *AuthProfile both + # validates AND dispatches) AND lets a registered specialization win over its + # registered base — exactly the §10 per-backend-OAuth2-subclass case (register + # both OAuth2AuthProfile and SnowflakeOAuth2Profile for SNOWFLAKE → a + # SnowflakeOAuth2Profile instance resolves to the subclass, MRO-first). + matches = [k for k in auth_class.__mro__ if (provider_type, k) in _AUTH_ADAPTERS] + if not matches: + return None + winner = matches[0] # nearest in MRO + # Ambiguity guard: every other match must be an ancestor of the winner. A match + # that is NOT a superclass of the winner means auth_class multiply-inherits two + # UNRELATED registered auth types for this provider_type — refuse to guess. + ambiguous = [k for k in matches[1:] if not issubclass(winner, k)] + if ambiguous: + raise TypeError( + f"ambiguous auth adapter for {auth_class.__name__} on {provider_type}: " + f"{winner.__name__} vs {[k.__name__ for k in ambiguous]} " + f"(multiply-inherits unrelated registered auth types)" + ) + return _AUTH_ADAPTERS[(provider_type, winner)] +``` + +Specialization (base + subclass both registered) is unambiguous — the subclass is +MRO-first and is a subclass of the base, so the guard passes. Only genuine +multiple-inheritance of two *sibling* registered auth types trips it, and then loudly. + +Each adapter is `(_auth_profile, base) -> dict`, reads UPPERCASE fields (`str(...)` on +`Path | None` where the driver wants a string), and builds the driver kwargs/objects. +Example: + +```python +# core/settings/adapters/trino.py +def password(auth, base): + from trino.auth import BasicAuthentication + return {**base, "auth": BasicAuthentication(auth.USERNAME, + auth.PASSWORD.get_secret_value())} +``` + +**`_snow.oauth2` is pinned to the already-obtained-token contract** — it emits exactly +`authenticator="oauth"` + `token=auth.TOKEN.get_secret_value()` and **nothing else**: + +```python +# core/settings/adapters/snowflake.py +def oauth2(auth, base): + # token-only: data does NOT drive snowflake's authorization-code / + # client-credentials flows (those need oauth_client_id / oauth_client_secret / + # oauth_token_request_url / oauth_scope — provider coordinates the oauth-split + # relocates off the credential schema; deferred to §10). Reads only TOKEN. + return {**base, "authenticator": "oauth", + "token": auth.TOKEN.get_secret_value()} +``` + +It must never read `CLIENT_ID`/`CLIENT_SECRET`/`SERVER_URI`/`SCOPE` — doing so would +reintroduce the layering guide's smell #1. The acquisition-from-coordinates path is the +deferred §10 work. + +`NoAuthProfile` is never in the table — the factory short-circuits it (§3.5). The +driver imports are local to each adapter module, so importing the settings layer +never pulls in `trino`/`google` unless that backend is actually used. + +### 3.5 The composing factory — `ConnectionFactory` (the `_emit_kwargs` analogue) + +Composition lives in the factory, not on the profile (transport's +`create_connection`/`_emit_kwargs`). `BackendProfile` stays a pure config emitter. + +```python +# core/factories/connection_factory.py +def build_driver_kwargs(profile: BackendProfile, auth_profile: AuthProfile | None = None) -> dict: + auth = _normalize_and_validate_auth(profile, auth_profile) # §6 + target = profile.__spec__.provider_type + base = profile.emit(target) # config only (§3.3) + if isinstance(auth, NoAuthProfile): + return base # short-circuit (cf. transport) + fn = auth_adapter(target, type(auth)) + if fn is None: # fail-closed + raise ValueError( + f"{profile.backend}: no auth adapter for {type(auth).__name__}" + ) + return fn(auth, base) # data-owned translation +``` + +``` +caller ── auth_profile (AuthProfile|None) ──▶ IbisBackend.connect(auth_profile) + │ (also iceberg connect path) + ▼ + ConnectionFactory.build_driver_kwargs(backend_profile, auth_profile) + auth = _normalize_and_validate_auth(profile, auth_profile) + target = profile.__spec__.provider_type + base = profile.emit(target) # config (BackendProfile) + │ + NoAuth? ──┴── yes ─▶ return base + │ no + auth_adapter(target, type(auth))(auth, base) # data-owned + │ (builds BasicAuthentication / Credentials / + ▼ {user,password} / …; imports the driver) + dict ready for the ibis driver ──▶ runtime Connection opens it +``` + +--- + +## 4. Component Changes + +### 4.1 `core/settings/__init__.py` +- Replace `from mountainash_settings.auth import (…)` with + `from mountainash_auth_client import (NoAuthProfile, PasswordAuthProfile, + TokenAuthProfile, JWTAuthProfile, OAuth2AuthProfile, IAMAuthProfile, + WindowsAuthProfile, AzureADAuthProfile, KerberosAuthProfile, + CertificateAuthProfile, ServiceAccountAuthProfile, AuthProfile)`. +- Update `__all__`: drop old `*Auth`/`AuthSpec` names; add the `*AuthProfile` + names + `AuthProfile`; rename the backend re-exports to `*BackendProfile`. +- No registration import — auth dispatch is a plain table loaded lazily by the + factory (§3.4); nothing must run at settings-import time. + +### 4.2 Delete `core/settings/auth/` +Remove `__init__.py`, `base.py`, `dispatch.py` (pure shims over the deleted +`mountainash_settings.auth`). Verified the only consumer of +`auth_to_driver_kwargs`/`AUTH_TO_DRIVER_KWARGS` is the shim itself. + +### 4.3 `core/settings/descriptor.py` (`BackendSpec`) +- Add a **required** `supported_auth: tuple[type, ...]` (no default; typed loosely as + `type` to avoid importing the union at dataclass-definition time; values are + `*AuthProfile` classes). Registry invariant: empty `supported_auth` fails at import. +- **Drop `auth_modes`** everywhere (gone with the upstream `ProfileSpec` path). +- No new `auth_target` field — the existing `provider_type` is the dispatch/emit key + (§3.3–§3.4). + +### 4.4 `core/settings/profile.py` (`BackendProfile`) — pure config emitter +- Rename the base class `ConnectionProfile` → `BackendProfile`. +- **Remove all auth coupling**: no `to_driver_kwargs`, no `to_connection_string` (the + auth-threading one), no `_auth_kwargs`, no `__adapter__`. +- The class exposes `emit(target)` (inherited) for its own driver-kwargs config, **and + a credential-free `to_url_parts() -> UrlParts`** with **no credentials**. This is + still pure L1 config rendering (a host/port/db skeleton is config, not auth), the + URL-target analogue of `emit()`; it keeps the profile as declarative as transport's + `StorageProfile`. Credentials are spliced in one layer down by the L3 URL applier + (§4.6), never here. +- **`UrlParts` is a dataclass, not a fixed 5-tuple** — every authority component is + optional so authority-less and account-path URL forms decompose cleanly: + + ```python + @dataclass(frozen=True) + class UrlParts: + scheme: str # "postgresql", "md", "snowflake" + database: str | None = None + host: str | None = None # None ⇒ authority-less (MotherDuck md:) + port: int | None = None + path: str | None = None # account/catalog forms not expressible as host:port + query: dict[str, str] = field(default_factory=dict) # creds-FREE params only + ``` + + The base `to_url_parts()` builds the standard `scheme://host:port/database` from the + common spec fields. **Backends whose URL doesn't fit the standard authority form + override `to_url_parts()`** — e.g. MotherDuck returns `UrlParts(scheme="md", + database=db)` (no host/port; the token is added to `query` later, by the L3 applier, + not here); a future Snowflake account URL populates `path`. The L3 URL applier (§4.6) + consumes `UrlParts` uniformly: password creds splice into the authority **iff `host` + is set** (authority-less schemes never take `user:pass@`), token creds go into + `query`. This keeps every URL quirk in a declarative per-backend `to_url_parts()`, + not smeared across the applier. + +### 4.5 `core/settings/adapters/` — data-owned adapter functions +- `adapters/.py` — the auth-translation functions `(_auth_profile, base) -> dict` + (driver imports local) **and** the 3 connection-shaping compose functions + `(profile, base) -> dict` referenced by the `__adapters__` literals (§3.3). +- `adapters/sql.py` — the shared flat `userpass(auth, base)`. +- `adapters/registry.py` — the `_AUTH_ADAPTERS` table + `auth_adapter()` lookup (§3.4). +- The old per-backend `build_driver_kwargs` modules and the + `__adapter__ = staticmethod(...)` lines are **removed**. + +### 4.6 New: `core/factories/connection_factory.py` +- `_normalize_and_validate_auth(profile, auth_profile) -> AuthProfile`: `None` → + `NoAuthProfile()`, then `isinstance`-validate against + `profile.__spec__.supported_auth`; clear `ValueError` on miss (§6). +- `build_driver_kwargs(profile, auth_profile=None) -> dict` — the §3.5 body. +- `build_connection_string(profile, auth_profile=None) -> str` — **a URL is a distinct + target, and the four layers still hold for it: config-render first (L1), then auth- + apply (L3), in two separate code paths** (never one fused method — that was the + smell in the deleted `to_connection_string`). The factory only *composes* them: + + ```python + def build_connection_string(profile, auth_profile=None) -> str: + auth = _normalize_and_validate_auth(profile, auth_profile) # §6, same gate + parts = profile.to_url_parts() # L1: creds-free skeleton (§4.4) + return _url_auth_applier(profile.__spec__.provider_type)(auth, parts) # L3: splice creds + ``` + + - **L1 — `profile.to_url_parts()`** renders the credential-free `(scheme, host, port, + database, query)` skeleton. No auth knowledge; no `emit()` (URLs aren't kwargs). + - **L3 — the per-`provider_type` URL applier** `(auth, parts) -> str` is the *only* + place creds meet the URL. It does **not** route through the `(provider_type, + auth_class)` *kwargs* table — a connection-string renders creds positionally + (`user:pass@`, `?token=`), a fundamentally different target shape than driver + kwargs — but it is the same L3 role applied to a second target, stated explicitly + so the two appliers don't drift: + - password-style: splice `user:pass@` into the authority for `PasswordAuthProfile` + (`USERNAME` / `PASSWORD.get_secret_value()`, each `quote(..., safe="")`) — valid + only when `parts.host` is set; an authority-less backend (no `host`) that claims + password URL support is rejected by the §6 URL-applier coverage check, not + silently emitted; `NoAuthProfile` → skeleton unchanged; + - token-in-URL backends (MotherDuck `md:?motherduck_token=…`, and any future + Snowflake/Databricks/Trino-JWT URL form) add the token to `query`; + - any other auth type → `NotImplementedError`. + - **Coverage:** each provider's URL applier declares its supported auth types as an + explicit, test-asserted set (§6) — **not** a silent subset of `supported_auth` — so + a backend whose `supported_auth` includes a type the URL applier can't render fails + loudly with `NotImplementedError`, never by emitting a credential-less URL. + +### 4.7 Rename `*AuthSettings` → `*BackendProfile` (20 backends) +Across all 20 modules, class definitions, `__init__.py` exports, and references. Drop +`auth_modes=[…]` from each `BackendSpec(...)`; add `supported_auth=(…AuthProfile, …)`. +mysql/mssql/snowflake additionally gain a class-literal `__adapters__` (§3.3). + +| Old | New | +|---|---| +| `SQLiteAuthSettings` | `SQLiteBackendProfile` | +| `PostgreSQLAuthSettings` | `PostgreSQLBackendProfile` | +| … (all 20) | `*BackendProfile` | + +### 4.8 Entry points +- `backends/ibis/backend.py`: `IbisBackend.connect(self, auth_profile=None)` is the + single auth entry point. **Defer auth-dependent kwargs to `connect()`** — today + `_init_from_settings` eagerly calls `to_driver_kwargs()` at `__init__` + (backend.py:242), before any `auth_profile` exists. Restructure so + `__init__`/`_init_from_settings` resolves only the dialect + spec and stores the + `BackendProfile`; `connect(auth_profile)` calls + `ConnectionFactory.build_driver_kwargs(profile, auth_profile)` and layers + `self._config`. The direct-dialect path is unaffected. +- **URL creds vs explicit `auth_profile` precedence (fail-closed, no silent + override):** URL `user:pass@` is parsed into a `PasswordAuthProfile` **only when no + explicit `auth_profile` is given**. Supplying **both** a URL with embedded creds and + an explicit `auth_profile` is a `ValueError` — neither silently wins; the ambiguity + is rejected. URL credentials are **stripped** before the URL reaches `ibis.connect` + (creds always travel via the auth profile, never the URL). +- `backends/iceberg/connection.py`: `connect_default(self, *, auth_profile=None, **kwargs)` + and `connect` thread `auth_profile` into + `ConnectionFactory.build_driver_kwargs(profile, auth_profile)`. Precedence: + **profile-derived kwargs < explicit `connection_kwargs`/`**kwargs`** (caller + overrides win); document on the methods. + +### 4.9 Dependency wiring +- `pyproject.toml`: add `mountainash-auth-client` to core `dependencies`. +- `hatch.toml`: add `mountainash_auth_client @ {root:uri}/../mountainash-auth-client` + (local: `dev`, `test`) and `{root:uri}/temp/mountainash-auth-client` (CI: + `test_github`, `build_github`), mirroring the existing settings/transport path deps. + Remove the dead `mountainash_utils_ssh` path-dep line where present (the package is + no longer a dependency of this layer). + +--- + +## 5. Field Mapping (old → new), per auth type + +Field names go UPPERCASE; secret-ness preserved. Path fields are `Path | None` — +adapters `str(...)` them where the driver wants a string. Verified against the old +adapter reads and the new `*AuthProfile` `ParameterSpec`s. + +| Auth | Old field(s) | New field(s) | Secret / type notes | +|---|---|---|---| +| Password | `username`, `password` | `USERNAME`, `PASSWORD` | PASSWORD secret | +| Token | `token` | `TOKEN` | TOKEN secret | +| JWT | `token` | `TOKEN` | TOKEN secret | +| Kerberos | `service_name`, `principal` | `SERVICE_NAME`, `PRINCIPAL`, `KEYTAB` | `KEYTAB: Path \| None` (unused by the trino adapter) | +| Windows | `domain`, `username` | `DOMAIN`, `USERNAME` | — | +| AzureAD | `tenant_id`, `client_id`, `client_secret`, `managed_identity`, `msi_endpoint` | `TENANT_ID`, `CLIENT_ID`, `CLIENT_SECRET`, `MANAGED_IDENTITY`, `MSI_ENDPOINT` | CLIENT_SECRET secret | +| IAM | `role_arn`, `access_key_id`, `secret_access_key`, `session_token`, `profile_name` | `ROLE_ARN`, `ACCESS_KEY_ID`, `SECRET_ACCESS_KEY`, `SESSION_TOKEN`, `PROFILE_NAME` | SECRET_ACCESS_KEY, SESSION_TOKEN secret | +| ServiceAccount | `info`, `file` | `INFO`, `FILE` | `FILE: Path \| None`; `INFO: dict \| None` | +| OAuth2 *(consumed)* | `token` | `TOKEN` | TOKEN secret — **the only OAuth2 field any shipped backend reads** (snowflake, token-only) | +| OAuth2 *(present, NOT consumed)* | `client_id`, `client_secret`, `refresh_token`, `server_uri`, `scope` | `CLIENT_ID`, `CLIENT_SECRET`, `REFRESH_TOKEN`, `SERVER_URI`, `SCOPE` | exist on `schemas/oauth2.py` but **no migration adapter may read them** — `SERVER_URI`/`SCOPE` are provider coordinates the oauth-split relocates; the rest belong to deferred acquisition (§10). **Field existence ≠ permission to consume.** | +| Certificate | `private_key`, `private_key_path`, `passphrase` | `PRIVATE_KEY`, `PRIVATE_KEY_PATH`, `PASSPHRASE` | PRIVATE_KEY, PASSPHRASE secret; `PRIVATE_KEY_PATH: Path \| None` | +| NoAuth | — | — | — | + +> **Scope:** rows are exactly the auth types a backend consumes. `OAuth1AuthProfile` +> and `OAuth2AuthCodeAuthProfile` are union members **no backend consumes** (verified: +> zero references) — no mapping, not in any `supported_auth`, not in the table. +> **pyiceberg-REST OAuth2 is descoped from this migration** (§10): its adapter would +> read `OAuth2AuthProfile.SERVER_URI`/`SCOPE`, which the locked +> `oauth-settings-ops-split` design **relocates to the `oauth/` provider profile** +> (they are provider coordinates, not credential data). Reading them off the +> credential schema now would couple this migration to the un-weave and ship the +> layering guide's smell #1 (protocol policy on a generic credential). pyiceberg +> ships **token-only** (§5.1); its OAuth2 path is deferred to §10 alongside the +> acquisition backlog. Snowflake's OAuth2 path **stays** — it reads only +> `OAuth2AuthProfile.TOKEN` (an externally-obtained token, i.e. genuine credential +> data), touching none of the relocating provider-coordinate fields. + +### 5.1 Per-backend `supported_auth` + +| Backend | provider_type | supported_auth | +|---|---|---| +| sqlite, duckdb, pyspark | SQLITE/DUCKDB/PYSPARK | `(NoAuthProfile,)` | +| postgres, clickhouse, singlestoredb, druid, impala, materialize, risingwave | … | `(PasswordAuthProfile, NoAuthProfile)` | +| mysql, exasol | MYSQL/EXASOL | `(PasswordAuthProfile,)` | +| motherduck | MOTHERDUCK | `(TokenAuthProfile,)` | +| trino | TRINO | `(PasswordAuthProfile, JWTAuthProfile, KerberosAuthProfile, NoAuthProfile)` | +| snowflake | SNOWFLAKE | `(PasswordAuthProfile, OAuth2AuthProfile, CertificateAuthProfile, TokenAuthProfile)` | +| bigquery | BIGQUERY | `(ServiceAccountAuthProfile, NoAuthProfile)` | +| databricks | DATABRICKS | `(TokenAuthProfile, PasswordAuthProfile, NoAuthProfile)` | +| redshift | REDSHIFT | `(PasswordAuthProfile, IAMAuthProfile)` | +| mssql | MSSQL | `(PasswordAuthProfile, WindowsAuthProfile, AzureADAuthProfile)` | +| pyiceberg_rest | PYICEBERG_REST | `(TokenAuthProfile,)` — OAuth2 deferred to §10 | + +--- + +## 6. Validation & Error Handling + +- Factory-level `_normalize_and_validate_auth(profile, auth_profile)` is called first + by **both** `build_driver_kwargs` and `build_connection_string`: `None` → + `NoAuthProfile()`, then `isinstance(auth, tuple(profile.__spec__.supported_auth))`; + on miss raise `ValueError(f"{profile.backend} does not support auth: + {type(auth).__name__}")`. `isinstance` (not exact `type()`) so subclasses are + accepted — **and dispatch is correspondingly MRO-aware** (§3.4 `auth_adapter` walks + the MRO), so an accepted subclass both validates *and* resolves to its registered + base's adapter. Validation and dispatch share the same subclass semantics; they + cannot disagree. +- Empty `supported_auth` is impossible: the registry invariant (§4.3) rejects it. +- **Fail-closed dispatch:** if `auth` passed `supported_auth` but + `auth_adapter(provider_type, type(auth))` is `None`, the factory raises (§3.5) — + an auth type listed as supported but missing its adapter is caught loudly, never by + emitting unauthenticated kwargs. +- A startup consistency check (test, §7) asserts every `(provider_type, auth_class)` + in each backend's `supported_auth` (minus `NoAuthProfile`) has a table entry, and + no table entry references an unsupported pair. +- **URL applier coverage:** `build_connection_string` is a distinct L3 target (§4.6), + so its auth handling is not the kwargs table. Its supported auth types are an + explicit per-`provider_type` set; the §7 URL test asserts every type **not** in that + set raises `NotImplementedError` (fail-closed — never a credential-less URL), so the + parallel structure stays covered rather than drifting. + +--- + +## 7. Testing Strategy + +- Update ~28 test files: imports → `mountainash_auth_client` (or the `core/settings` + re-exports); construction → UPPERCASE kwargs + (`PasswordAuthProfile(USERNAME="u", PASSWORD="p")`); auth passed as a separate arg, + not an `auth=` field. `tests/fixtures/settings_fixtures.py` yields + `(backend_profile, auth_profile)` pairs. +- New focused tests: + - **Golden per (dialect, auth type):** `build_driver_kwargs(profile, auth)` yields + the exact driver-kwargs dict (trino → `auth=BasicAuthentication(...)`; bigquery → + `credentials=…`; postgres → `{user, password}`; …). Mirrors transport's emission + goldens. + - **Config-only emit + key-delta:** `profile.emit(provider_type)` golden for the 3 + shaping backends (mysql `ssl={}`, mssql host-fold, snowflake `session_parameters`). + Beyond the combined-key value, the test diffs the pre-compose renamed dict against + the post-compose dict and asserts **only the sanctioned delta** (§3.3): mysql/ + snowflake are pure additions (all prior keys byte-identical); mssql changes only + `host` and emits no `instance_name`. Any other changed key fails — mechanically + locking the no-double-render invariant, not merely checking for leaked keys. + - **`supported_auth` rejection:** out-of-`supported_auth` type → `ValueError` (one + negative per backend). + - **`None` normalization:** no auth → `NoAuthProfile` accepted for no-auth backends, + rejected for credential-required backends. + - **Subclass end-to-end (validation + dispatch):** a subclass of an allowed + `*AuthProfile` is both accepted by validation **and** dispatched to its registered + base's adapter (proving §3.4's MRO walk and §6's `isinstance` agree — guards the + blocker where exact-`type()` dispatch would have crashed a validated subclass). + - **Dispatch resolution (§3.4):** (a) *specialization* — with both a base and its + subclass registered for one `provider_type`, a subclass instance resolves to the + subclass adapter (most-specific-first); (b) *ambiguity* — a class multiply- + inheriting two **sibling** registered auth types for one `provider_type` raises + `TypeError`, never silently picking one. + - **Fail-closed dispatch:** a supported auth type with no table entry → `ValueError`. + - **Table/`supported_auth` consistency** (§6). + - **`build_connection_string`:** password backend → `user:pass@` (percent-encoded, + secret unwrapped); MotherDuck → `…?motherduck_token=`; other type → + `NotImplementedError`. + - **Registry invariant:** empty `supported_auth` fails at import. + - **URL-vs-explicit precedence:** both → `ValueError`; URL-only → creds stripped and + carried via `PasswordAuthProfile`. +- Acceptance gate: `hatch run test:test` green; `mypy:check` clean; `ruff:check` clean. + +--- + +## 8. Isolation & Interfaces + +- **auth-client** — owns the credential schemas (`*AuthProfile`) and ships + `emit()` adapters for its own HTTP/BOTO/PARAMIKO families as **reference + implementations**. mountainash-data reads its profile fields directly and + **registers nothing onto it**. +- **`*BackendProfile`** — owns backend config + its own `emit(provider_type)`; pure + config, no auth methods (transport `StorageProfile` analogue). +- **auth adapters** (`core/settings/adapters/.py` + `sql.py`) — data-owned + `(auth_profile, base) -> dict`; the only place that knows a driver's auth-kwarg + shape; import the DB drivers; independently unit-testable without a live DB. +- **`adapters/registry.py`** — the `(provider_type, auth_class) -> fn` dispatch table. +- **`ConnectionFactory`** — the composing bridge (`build_driver_kwargs` / + `build_connection_string` / `_normalize_and_validate_auth`); the only layer aware + of *both* a backend profile and an auth profile. +- **runtime** (`IbisBackend`/`IbisConnection`, `IcebergConnection`) — consume the + finished kwargs dict. + +This depends on **no settings extension primitive** — only the stable +`mountainash-settings` `Profile`/`ProfileSpec`/`emit()` surface and +`mountainash-auth-client`'s profile classes. + +--- + +## 9. Rollout + +A single feature branch off mountainash-data `develop` → PR to `develop`. Internally +atomic (the package does not import cleanly until the settings layer is migrated). +Suggested commit slices: +(a) deps + `__init__` import swap + delete `auth/` shim; +(b) descriptor (`supported_auth` + invariant, drop `auth_modes`) + `BackendProfile` +rename to a pure `emit` config class; +(c) the 20 renames `*AuthSettings`→`*BackendProfile` + `supported_auth` + the +mysql/mssql/snowflake connection-shaping `__adapters__` literals; +(d) the data-owned auth adapters + `adapters/registry.py`; +(e) `ConnectionFactory` (`_normalize_and_validate_auth`, `build_driver_kwargs`, +`build_connection_string`); +(f) entry points (deferred-auth `IbisBackend`, URL precedence, iceberg threading); +(g) tests. + +**No dependency on the settings `Profile.register_adapter` primitive.** That primitive +(settings PR #47) has no consumer under this design; reverting it from +mountainash-settings is a separate, recommended cleanup tracked outside this spec. + +--- + +## 10. Backlog (deferred, captured) + +**Interactive OAuth acquisition & token persistence.** Snowflake's OAuth path ships +in this migration but only consumes an **already-obtained** token read statically off +the auth profile (`auth.TOKEN`). A future capability should integrate the wearables +lifecycle so mountainash-data can **acquire and refresh** tokens itself: +`OAuth2TokenManager(provider, auth_profile, resolver=…)`; `PersistableAuthProfile` +(`SETTINGS_SOURCE_SECRETS_PROVIDER` + `persist_key()`) + `token_store()`; a +`SecretStoreResolver` + `mountainash-secrets` wiring; likely a small per-OAuth-backend +subclass (à la wearables' `WearableOAuth2Auth`). + +**pyiceberg-REST OAuth2 (descoped from this migration).** pyiceberg's OAuth2 catalog +auth needs `SERVER_URI`/`SCOPE` (token-endpoint coordinates) plus a credential. The +locked `oauth-settings-ops-split` design relocates `SERVER_URI`/`SCOPE` off the +credential schema into the `oauth/` provider profile, so a clean pyiceberg-OAuth2 +adapter must consume *that* provider profile, not `OAuth2AuthProfile` — work that +belongs after the auth-client un-weave lands. Until then pyiceberg ships +`(TokenAuthProfile,)`: a caller with an externally-obtained catalog token is fully +served; OAuth2-handshake-from-coordinates is the deferred piece. Both items tracked as +follow-up issues after this migration merges; both are gated on the auth-client +un-weave only for the provider-coordinate reads, not for the token-only paths. + +--- + +## 11. Open Questions + +None outstanding. Auth placement = decoupled, composed in the factory; compat = clean +break; rename = `*BackendProfile` ("Connection" reserved for runtime); auth +translation = **data-owned** direct-field adapters in a `(provider_type, auth_class)` +table (no `register_adapter`, no `emit()` on auth profiles, no cross-package +mutation); config = `BackendProfile.emit()` with class-literal `__adapters__` on the 3 +non-flat backends; OAuth lifecycle **and pyiceberg-REST OAuth2** = deferred (§10, +gated on the auth-client un-weave); snowflake OAuth2 ships (token-only read). diff --git a/hatch.toml b/hatch.toml index 78283e6..90338eb 100644 --- a/hatch.toml +++ b/hatch.toml @@ -20,7 +20,8 @@ dependencies = [ "mountainash_settings @ {root:uri}/../mountainash-settings", "mountainash @ {root:uri}/../mountainash", "mountainash_transport @ {root:uri}/../mountainash-transport", - "mountainash_utils_ssh @ {root:uri}/../mountainash-utils-ssh", + "mountainash_secrets @ {root:uri}/../mountainash-secrets", + "mountainash_auth_client @ {root:uri}/../mountainash-auth-client", ] @@ -35,7 +36,8 @@ dependencies = [ "mountainash_settings @ {root:uri}/temp/mountainash-settings", "mountainash @ {root:uri}/temp/mountainash", "mountainash_transport @ {root:uri}/temp/mountainash-transport", - "mountainash_utils_ssh @ {root:uri}/temp/mountainash-utils-ssh", + "mountainash_secrets @ {root:uri}/temp/mountainash-secrets", + "mountainash_auth_client @ {root:uri}/temp/mountainash-auth-client", ] [envs.build_github.scripts] @@ -83,7 +85,8 @@ dependencies = [ "mountainash_settings @ {root:uri}/temp/mountainash-settings", "mountainash @ {root:uri}/temp/mountainash", "mountainash_transport @ {root:uri}/temp/mountainash-transport", - "mountainash_utils_ssh @ {root:uri}/temp/mountainash-utils-ssh", + "mountainash_secrets @ {root:uri}/temp/mountainash-secrets", + "mountainash_auth_client @ {root:uri}/temp/mountainash-auth-client", ] @@ -121,7 +124,8 @@ dependencies = [ "mountainash_settings @ {root:uri}/../mountainash-settings", "mountainash @ {root:uri}/../mountainash", "mountainash_transport @ {root:uri}/../mountainash-transport", - "mountainash_utils_ssh @ {root:uri}/../mountainash-utils-ssh", + "mountainash_secrets @ {root:uri}/../mountainash-secrets", + "mountainash_auth_client @ {root:uri}/../mountainash-auth-client", ] @@ -287,6 +291,16 @@ radon-cc-detail = "radon cc ./src" # Mypy Type checks [envs.mypy] installer = "uv" -dependencies = ["mypy==1.10.1"] +dependencies = [ + "mypy==1.10.1", + "pytest==8.3.5", + "pytest-mock==3.12.0", + "pytest-check==2.5.3", + "mountainash_settings @ {root:uri}/../mountainash-settings", + "mountainash @ {root:uri}/../mountainash", + "mountainash_transport @ {root:uri}/../mountainash-transport", + "mountainash_secrets @ {root:uri}/../mountainash-secrets", + "mountainash_auth_client @ {root:uri}/../mountainash-auth-client", +] [envs.mypy.scripts] check = "mypy --install-types --non-interactive {args:src/mountainash_data tests}" diff --git a/pyproject.toml b/pyproject.toml index 1691d9e..534e860 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,3 +100,20 @@ tests = ["tests", "*/mountainash-data/tests"] [tool.coverage.report] exclude_lines = ["no cov", "if __name__ == .__main__:", "if TYPE_CHECKING:"] +[tool.mypy] +# Internal mountainash siblings ship no py.typed marker, and optional drivers +# (trino/google/pyiceberg/mountainash_dataframes) are not installed in the type +# env — treat all unresolved/untyped third-party imports as Any rather than noise. +ignore_missing_imports = true +disable_error_code = ["import-untyped"] + +# Pre-existing type debt in the Iceberg backend (catalog_backend None-narrowing, +# pyiceberg Any-typed surfaces, hook-signature mismatches) — NOT introduced by +# the auth-client migration and in code paths that require the optional pyiceberg +# + mountainash_dataframes stack to exercise. Carved out for a separate Iceberg +# type-hardening pass; the auth-migration code (factories/adapters/registry/ibis) +# is type-checked normally. +[[tool.mypy.overrides]] +module = "mountainash_data.backends.iceberg.*" +ignore_errors = true + diff --git a/src/mountainash_data/backends/ibis/backend.py b/src/mountainash_data/backends/ibis/backend.py index e39a7bd..7c13781 100644 --- a/src/mountainash_data/backends/ibis/backend.py +++ b/src/mountainash_data/backends/ibis/backend.py @@ -16,6 +16,13 @@ NamespaceInfo, TableInfo, ) +from mountainash_data.core.factories.connection_factory import ( + build_driver_kwargs, + apply_auth_adapter, + provider_for_dialect, + provider_for_scheme, +) +from mountainash_auth_client import PasswordAuthProfile class IbisConnection: @@ -194,7 +201,9 @@ def _init_from_dialect( self.dialect = dialect_name self._spec: DialectSpec = DIALECTS[dialect_name] self._url: str | None = None - self._config = config + self._profile = None + self._dialect_config = config + self._config: dict[str, t.Any] | None = None self._conn: IbisConnection | None = None def _init_from_url( @@ -205,6 +214,7 @@ def _init_from_url( scheme = urlparse(url).scheme.lower() # Special case: MotherDuck URLs are "duckdb://md:..." + resolved_dialect: str | None if scheme == "duckdb" and url.startswith("duckdb://md:"): resolved_dialect = "motherduck" else: @@ -218,8 +228,10 @@ def _init_from_url( self.dialect = resolved_dialect self._spec = DIALECTS[resolved_dialect] self._url = url - self._config = config - self._conn: IbisConnection | None = None + self._profile = None + self._url_config = config + self._config = None + self._conn = None def _init_from_settings( self, settings_params: t.Any, config: dict[str, t.Any] @@ -239,14 +251,13 @@ def _init_from_settings( f"Unknown ibis dialect {resolved_dialect!r} from spec. " f"Available: {sorted(DIALECTS)}" ) - driver_kwargs = obj_settings.to_driver_kwargs() - driver_kwargs.update(config) - self.dialect = resolved_dialect self._spec = DIALECTS[resolved_dialect] self._url = None - self._config = driver_kwargs - self._conn: IbisConnection | None = None + self._profile = obj_settings # settings path + self._extra_config = config # caller **config overrides + self._config = None + self._conn = None def _require_connected(self) -> IbisConnection: if self._conn is None: @@ -255,26 +266,77 @@ def _require_connected(self) -> IbisConnection: ) return self._conn - def connect(self) -> IbisBackend: - """Build a live ibis connection. Returns self for fluent chaining.""" + def connect(self, auth_profile: t.Any = None) -> IbisBackend: + """Build a live ibis connection, optionally applying an auth profile. + + auth_profile is L2 credential data (a *AuthProfile). It is composed onto + the backend config here (L3) — config is built at connect time, not init. + Returns self for fluent chaining. + """ if self._conn is not None: return self if self._spec.connection_builder is None: raise NotImplementedError( f"Dialect {self.dialect!r} has no connection_builder configured" ) - if self._url is not None: + if self._profile is not None: # settings path + cfg = build_driver_kwargs(self._profile, auth_profile) + cfg.update(self._extra_config) + self._config = cfg + ibis_conn = self._connect_via_builder() + elif self._url is not None: # URL path + config, clean_url = self._resolve_url_auth(self._url, auth_profile) + config.update(self._url_config) # caller extras apply on top + self._config = config import ibis - ibis_conn = ibis.connect(self._url, **self._config) - else: - cleaned_config = { - k: v for k, v in self._config.items() - if not (isinstance(v, (list, tuple)) and len(v) == 0) - } - ibis_conn = self._spec.connection_builder(**cleaned_config) + ibis_conn = ibis.connect(clean_url, **self._config) + else: # direct-dialect path + self._config = self._resolve_dialect_auth(auth_profile) + ibis_conn = self._connect_via_builder() self._conn = IbisConnection(ibis_conn, self._spec) return self + def _connect_via_builder(self) -> t.Any: + # preserves the prior empty-list/tuple filtering before connection_builder + assert self._config is not None # connect() sets it before dispatch + assert self._spec.connection_builder is not None # guarded in connect() + cleaned_config = { + k: v for k, v in self._config.items() + if not (isinstance(v, (list, tuple)) and len(v) == 0) + } + return self._spec.connection_builder(**cleaned_config) + + def _resolve_dialect_auth(self, auth_profile: t.Any) -> dict[str, t.Any]: + base = dict(self._dialect_config) + if auth_profile is None: + return base + provider = provider_for_dialect(self.dialect) + return apply_auth_adapter(provider, base, auth_profile) + + def _resolve_url_auth(self, url: str, auth_profile: t.Any) -> tuple[dict[str, t.Any], str]: + from urllib.parse import urlsplit, urlunsplit, unquote + parts = urlsplit(url) + has_url_creds = bool(parts.username) + if has_url_creds and auth_profile is not None: + raise ValueError( + "both URL credentials and an explicit auth_profile given" + ) + config: dict[str, t.Any] = {} + clean = url + if has_url_creds: + netloc = parts.hostname or "" + if parts.port: + netloc += f":{parts.port}" + clean = urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + auth_profile = PasswordAuthProfile( + USERNAME=unquote(parts.username or ""), + PASSWORD=unquote(parts.password) if parts.password else "", + ) + if auth_profile is not None: + provider = provider_for_scheme(parts.scheme) + config = apply_auth_adapter(provider, config, auth_profile) + return config, clean + def close(self) -> IbisBackend: """Release the connection. Idempotent. Returns self.""" if self._conn is not None: @@ -552,7 +614,8 @@ def index_exists( f"Dialect {self.dialect!r} does not support index_exists" ) conn = self._require_connected() - check_sql = self._spec.get_index_exists_sql(index_name, table_name, database) + # pre-existing: hook signature types table_name as str; not migration scope + check_sql = self._spec.get_index_exists_sql(index_name, table_name, database) # type: ignore[arg-type] result = conn._ibis_conn.sql(check_sql) if result is None: return False diff --git a/src/mountainash_data/backends/iceberg/catalogs/rest.py b/src/mountainash_data/backends/iceberg/catalogs/rest.py index af45495..ff7fd1f 100644 --- a/src/mountainash_data/backends/iceberg/catalogs/rest.py +++ b/src/mountainash_data/backends/iceberg/catalogs/rest.py @@ -32,7 +32,7 @@ from mountainash_settings import SettingsParameters from mountainash_data.core.constants import CONST_DB_BACKEND -from mountainash_data.core.settings import PyIcebergRestAuthSettings +from mountainash_data.core.settings import PyIcebergRestBackendProfile from mountainash_data.backends.iceberg.connection import IcebergConnectionBase @@ -62,7 +62,7 @@ def db_backend_name(self) -> str: @property def settings_class(self) -> t.Type[BaseSettings]: - return PyIcebergRestAuthSettings + return PyIcebergRestBackendProfile # ------------------------------------------------------------------ # List tables (instance-method version; fixes the broken classmethod diff --git a/src/mountainash_data/backends/iceberg/connection.py b/src/mountainash_data/backends/iceberg/connection.py index 2c454ee..b49b040 100644 --- a/src/mountainash_data/backends/iceberg/connection.py +++ b/src/mountainash_data/backends/iceberg/connection.py @@ -35,6 +35,7 @@ TableInfo, ) +from mountainash_data.core.factories.connection_factory import build_driver_kwargs from mountainash_dataframes import DataFrameUtils, SupportedDataFrames from mountainash_dataframes.constants import CONST_DATAFRAME_FRAMEWORK from mountainash_settings import SettingsParameters @@ -92,27 +93,47 @@ def connect( self, connection_string: t.Optional[str] = None, connection_kwargs: t.Optional[t.Dict[str, t.Any]] = None, + *, + auth_profile: t.Any = None, **kwargs: t.Any, ) -> Catalog: """Ensure a catalog connection is open, returning the backend handle. - If already connected, this is a no-op (idempotent). + Idempotent. ``auth_profile`` is L2 credential data (a *AuthProfile) + composed onto the catalog config at connect time. Precedence: + profile-derived config < explicit ``connection_kwargs``/``kwargs``. """ if self.catalog_backend is None: - self.connect_default(**kwargs) + self.connect_default( + auth_profile=auth_profile, **(connection_kwargs or {}), **kwargs + ) return self.catalog_backend - def connect_default(self, **kwargs: t.Any) -> Catalog: - """Connect using credentials from the configured settings class.""" + def connect_default(self, *, auth_profile: t.Any = None, **kwargs: t.Any) -> Catalog: + """Connect using settings credentials plus an optional auth profile. + + Precedence: profile-derived config < explicit ``kwargs``. + """ if self.catalog_backend is None: - settings_class = self.db_auth_settings_parameters.settings_class - if settings_class is None: - raise ValueError("Settings class is required for the database connection") - obj_settings = settings_class.get_settings(settings_parameters=self.db_auth_settings_parameters) - connection_kwargs = obj_settings.to_driver_kwargs() + connection_kwargs = self._build_catalog_kwargs(auth_profile, **kwargs) self._catalog_backend: RestCatalog = RestCatalog(**connection_kwargs) return self.catalog_backend + def _build_catalog_kwargs(self, auth_profile: t.Any = None, **kwargs: t.Any) -> dict: + """Build RestCatalog kwargs from settings + auth (no pyiceberg use here). + + Explicit ``kwargs`` override profile-derived config. + """ + settings_class = self.db_auth_settings_parameters.settings_class + if settings_class is None: + raise ValueError("Settings class is required for the database connection") + obj_settings = settings_class.get_settings( + settings_parameters=self.db_auth_settings_parameters + ) + connection_kwargs = build_driver_kwargs(obj_settings, auth_profile) + connection_kwargs.update(kwargs) + return connection_kwargs + def _connect( self, connection_kwargs: t.Optional[t.Dict[str, str]], diff --git a/src/mountainash_data/core/factories/__init__.py b/src/mountainash_data/core/factories/__init__.py new file mode 100644 index 0000000..72c8c11 --- /dev/null +++ b/src/mountainash_data/core/factories/__init__.py @@ -0,0 +1 @@ +"""Factories that compose backend config + auth into runtime kwargs.""" diff --git a/src/mountainash_data/core/factories/connection_factory.py b/src/mountainash_data/core/factories/connection_factory.py new file mode 100644 index 0000000..60c37af --- /dev/null +++ b/src/mountainash_data/core/factories/connection_factory.py @@ -0,0 +1,129 @@ +"""ConnectionFactory: compose BackendProfile config + AuthProfile creds.""" +from __future__ import annotations +import typing as t +from urllib.parse import quote + +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile, TokenAuthProfile, AuthProfile +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings.profile import UrlParts +from mountainash_data.core.settings.adapters.registry import auth_adapter + + +def _iter_specs() -> t.Iterator[t.Any]: + """Registered BackendSpecs, regardless of whether the registry stores + specs or profile classes.""" + from mountainash_data.core.settings.registry import REGISTRY + for v in REGISTRY.values(): + yield v.__spec__ if hasattr(v, "__spec__") else v + + +def provider_for_dialect(dialect: str) -> t.Any: + matches = [ + spec.provider_type + for spec in _iter_specs() + if getattr(spec, "ibis_dialect", None) == dialect + ] + if not matches: + raise KeyError(f"no provider_type for ibis dialect {dialect!r}") + if len(matches) == 1: + return matches[0] + # Multiple providers share one ibis dialect (postgres: postgresql+redshift; + # duckdb: duckdb+motherduck). Resolve deterministically to the canonical base. + for pt in matches: # exact provider name == dialect wins + if pt.name.lower() == dialect: + return pt + corresponding = sorted( # else the name-corresponding match, deterministically + (pt for pt in matches + if pt.name.lower().startswith(dialect) or dialect.startswith(pt.name.lower())), + key=lambda p: p.name, + ) + if corresponding: + return corresponding[0] + return sorted(matches, key=lambda p: p.name)[0] + + +def provider_for_scheme(scheme: str) -> t.Any: + norm = scheme.rstrip(":/") + for spec in _iter_specs(): + s = getattr(spec, "connection_string_scheme", None) + if s and s.rstrip(":/") == norm: + return spec.provider_type + raise KeyError(f"no provider_type for URL scheme {scheme!r}") + + +def _normalize_and_validate_auth(profile: t.Any, auth_profile: AuthProfile | None) -> AuthProfile: + auth = NoAuthProfile() if auth_profile is None else auth_profile + if not isinstance(auth, tuple(profile.__spec__.supported_auth)): + raise ValueError(f"{profile.backend} does not support auth: {type(auth).__name__}") + return auth + + +def apply_auth_adapter(provider_type: t.Any, base: dict, auth_profile: AuthProfile | None) -> dict: + """Apply auth WITHOUT a profile (ibis dialect / URL paths). No supported_auth gate.""" + if auth_profile is None or isinstance(auth_profile, NoAuthProfile): + return base + fn = auth_adapter(provider_type, type(auth_profile)) + if fn is None: + raise ValueError(f"{provider_type}: no auth adapter for {type(auth_profile).__name__}") + base = dict(base) + return fn(auth_profile, base) + + +def build_driver_kwargs(profile: t.Any, auth_profile: AuthProfile | None = None) -> dict: + auth = _normalize_and_validate_auth(profile, auth_profile) + target = profile.__spec__.provider_type + base = profile.emit(target) + if isinstance(auth, NoAuthProfile): + return base + fn = auth_adapter(target, type(auth)) + if fn is None: + raise ValueError(f"{profile.backend}: no auth adapter for {type(auth).__name__}") + return fn(auth, base) + + +# --- URL appliers (L3 for the URL target) --------------------------------- + +def _url_password(parts: UrlParts, auth: t.Any) -> str: + if parts.host is None: + raise NotImplementedError("password URL form requires a host authority") + user, pw = quote(str(auth.USERNAME), safe=""), quote(auth.PASSWORD.get_secret_value(), safe="") + url = f"{parts.scheme}://{user}:{pw}@{parts.host}" + if parts.port is not None: + url += f":{parts.port}" + if parts.database is not None: + url += f"/{parts.database}" + return url + + +def _url_noauth(parts: UrlParts) -> str: + if parts.host is None: + # authority-less form (e.g. an in-process/file backend): scheme:database + return f"{parts.scheme}:{parts.database}" if parts.database is not None else f"{parts.scheme}:" + url = f"{parts.scheme}://{parts.host}" + if parts.port is not None: + url += f":{parts.port}" + if parts.database is not None: + url += f"/{parts.database}" + return url + + +def _url_motherduck_token(parts: UrlParts, auth: t.Any) -> str: + return f"{parts.scheme}:{parts.database}?motherduck_token={auth.TOKEN.get_secret_value()}" + + +_URL_APPLIERS: dict[t.Any, dict[type, t.Callable]] = { + P.MOTHERDUCK: {TokenAuthProfile: _url_motherduck_token}, +} + + +def build_connection_string(profile: t.Any, auth_profile: AuthProfile | None = None) -> str: + auth = _normalize_and_validate_auth(profile, auth_profile) + parts = profile.to_url_parts() # L1 + if isinstance(auth, NoAuthProfile): + return _url_noauth(parts) + if isinstance(auth, PasswordAuthProfile): + return _url_password(parts, auth) # L3 + applier = _URL_APPLIERS.get(profile.__spec__.provider_type, {}).get(type(auth)) + if applier is None: + raise NotImplementedError(f"{profile.backend}: no URL form for {type(auth).__name__}") + return applier(parts, auth) diff --git a/src/mountainash_data/core/settings/__init__.py b/src/mountainash_data/core/settings/__init__.py index ca43405..6b7f8f7 100644 --- a/src/mountainash_data/core/settings/__init__.py +++ b/src/mountainash_data/core/settings/__init__.py @@ -1,14 +1,10 @@ -"""Backend settings — declarative spec + registry. - -The *AuthSettings classes below are stable import anchors; internally each -class body is a two-line shell (``__spec__`` + ``__adapter__``). -""" +"""Backend settings — declarative spec + registry.""" from __future__ import annotations # Core primitives from .descriptor import MISSING, Missing, BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile, UrlParts from .registry import ( DATABASES_REGISTRY, REGISTRY, @@ -18,42 +14,34 @@ class body is a two-line shell (``__spec__`` + ``__adapter__``). ) # Auth union members -from mountainash_settings.auth import ( - AuthSpec, - AzureADAuth, - CertificateAuth, - IAMAuth, - JWTAuth, - KerberosAuth, - NoAuth, - OAuth2Auth, - PasswordAuth, - ServiceAccountAuth, - TokenAuth, - WindowsAuth, +from mountainash_auth_client import ( + AuthProfile, NoAuthProfile, PasswordAuthProfile, TokenAuthProfile, + JWTAuthProfile, OAuth2AuthProfile, IAMAuthProfile, WindowsAuthProfile, + AzureADAuthProfile, KerberosAuthProfile, CertificateAuthProfile, + ServiceAccountAuthProfile, ) -# Per-backend settings classes (these import-register themselves). -from .sqlite import SQLiteAuthSettings -from .duckdb import DuckDBAuthSettings -from .motherduck import MotherDuckAuthSettings -from .postgresql import PostgreSQLAuthSettings -from .clickhouse import ClickHouseAuthSettings -from .databricks import DatabricksAuthSettings -from .mysql import MySQLAuthSettings -from .singlestoredb import SingleStoreDBAuthSettings -from .mssql import MSSQLAuthSettings -from .snowflake import SnowflakeAuthSettings -from .bigquery import BigQueryAuthSettings -from .redshift import RedshiftAuthSettings -from .pyspark import PySparkAuthSettings -from .trino import TrinoAuthSettings -from .exasol import ExasolAuthSettings -from .impala import ImpalaAuthSettings -from .materialize import MaterializeAuthSettings -from .risingwave import RisingWaveAuthSettings -from .druid import DruidAuthSettings -from .pyiceberg_rest import PyIcebergRestAuthSettings +# Per-backend profile classes (these import-register themselves). +from .sqlite import SQLiteBackendProfile +from .duckdb import DuckDBBackendProfile +from .motherduck import MotherDuckBackendProfile +from .postgresql import PostgreSQLBackendProfile +from .clickhouse import ClickHouseBackendProfile +from .databricks import DatabricksBackendProfile +from .mysql import MySQLBackendProfile +from .singlestoredb import SingleStoreDBBackendProfile +from .mssql import MSSQLBackendProfile +from .snowflake import SnowflakeBackendProfile +from .bigquery import BigQueryBackendProfile +from .redshift import RedshiftBackendProfile +from .pyspark import PySparkBackendProfile +from .trino import TrinoBackendProfile +from .exasol import ExasolBackendProfile +from .impala import ImpalaBackendProfile +from .materialize import MaterializeBackendProfile +from .risingwave import RisingWaveBackendProfile +from .druid import DruidBackendProfile +from .pyiceberg_rest import PyIcebergRestBackendProfile import warnings as _warnings @@ -78,21 +66,22 @@ def __getattr__(name: str): __all__ = [ # primitives - "MISSING", "Missing", "BackendSpec", "ParameterSpec", "ConnectionProfile", + "MISSING", "Missing", "BackendSpec", "ParameterSpec", "BackendProfile", "UrlParts", "DATABASES_REGISTRY", "REGISTRY", "get_descriptor", "get_settings_class", "register", # auth - "AuthSpec", "NoAuth", "PasswordAuth", "TokenAuth", "JWTAuth", - "OAuth2Auth", "ServiceAccountAuth", "IAMAuth", "WindowsAuth", - "AzureADAuth", "KerberosAuth", "CertificateAuth", + "AuthProfile", "NoAuthProfile", "PasswordAuthProfile", "TokenAuthProfile", + "JWTAuthProfile", "OAuth2AuthProfile", "IAMAuthProfile", "WindowsAuthProfile", + "AzureADAuthProfile", "KerberosAuthProfile", "CertificateAuthProfile", + "ServiceAccountAuthProfile", # backends - "SQLiteAuthSettings", "DuckDBAuthSettings", "MotherDuckAuthSettings", - "PostgreSQLAuthSettings", "ClickHouseAuthSettings", - "DatabricksAuthSettings", "MySQLAuthSettings", "SingleStoreDBAuthSettings", - "MSSQLAuthSettings", - "SnowflakeAuthSettings", "BigQueryAuthSettings", "RedshiftAuthSettings", - "PySparkAuthSettings", "TrinoAuthSettings", - "ExasolAuthSettings", "ImpalaAuthSettings", "MaterializeAuthSettings", - "RisingWaveAuthSettings", "DruidAuthSettings", - "PyIcebergRestAuthSettings", + "SQLiteBackendProfile", "DuckDBBackendProfile", "MotherDuckBackendProfile", + "PostgreSQLBackendProfile", "ClickHouseBackendProfile", + "DatabricksBackendProfile", "MySQLBackendProfile", "SingleStoreDBBackendProfile", + "MSSQLBackendProfile", + "SnowflakeBackendProfile", "BigQueryBackendProfile", "RedshiftBackendProfile", + "PySparkBackendProfile", "TrinoBackendProfile", + "ExasolBackendProfile", "ImpalaBackendProfile", "MaterializeBackendProfile", + "RisingWaveBackendProfile", "DruidBackendProfile", + "PyIcebergRestBackendProfile", ] diff --git a/src/mountainash_data/core/settings/adapters/bigquery.py b/src/mountainash_data/core/settings/adapters/bigquery.py index affe452..6aaa3dd 100644 --- a/src/mountainash_data/core/settings/adapters/bigquery.py +++ b/src/mountainash_data/core/settings/adapters/bigquery.py @@ -1,28 +1,13 @@ -"""BigQuery adapter: convert ServiceAccountAuth → google Credentials.""" - +"""BigQuery auth adapter functions.""" from __future__ import annotations - import typing as t -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_kwargs() - - auth = profile.auth - if isinstance(auth, ServiceAccountAuth): - from google.oauth2 import service_account as _sa - if auth.info is not None: - kwargs["credentials"] = _sa.Credentials.from_service_account_info(auth.info) - elif auth.file is not None: - kwargs["credentials"] = _sa.Credentials.from_service_account_file( - str(auth.file) - ) - elif isinstance(auth, NoAuth): - pass # Application Default Credentials - return kwargs +def service_account(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + from google.oauth2 import service_account as _sa + out = dict(base) + if auth.INFO is not None: + out["credentials"] = _sa.Credentials.from_service_account_info(auth.INFO) + elif auth.FILE is not None: + out["credentials"] = _sa.Credentials.from_service_account_file(str(auth.FILE)) + return out diff --git a/src/mountainash_data/core/settings/adapters/databricks.py b/src/mountainash_data/core/settings/adapters/databricks.py index f23986c..7c395f0 100644 --- a/src/mountainash_data/core/settings/adapters/databricks.py +++ b/src/mountainash_data/core/settings/adapters/databricks.py @@ -1,22 +1,11 @@ -"""Databricks adapter: maps TokenAuth → access_token, PasswordAuth → user/pass.""" - +"""Databricks auth adapter functions.""" from __future__ import annotations - import typing as t -from mountainash_settings.auth import PasswordAuth, TokenAuth - -if t.TYPE_CHECKING: - from mountainash_data.core.settings.databricks import DatabricksAuthSettings +def token(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + return {**base, "access_token": auth.TOKEN.get_secret_value()} -def build_driver_kwargs(profile: "DatabricksAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_kwargs() - auth = profile.auth - if isinstance(auth, TokenAuth): - kwargs["access_token"] = auth.token.get_secret_value() - elif isinstance(auth, PasswordAuth): - kwargs["username"] = auth.username - kwargs["password"] = auth.password.get_secret_value() - return kwargs +def password(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + return {**base, "username": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} diff --git a/src/mountainash_data/core/settings/adapters/motherduck.py b/src/mountainash_data/core/settings/adapters/motherduck.py new file mode 100644 index 0000000..81d5091 --- /dev/null +++ b/src/mountainash_data/core/settings/adapters/motherduck.py @@ -0,0 +1,8 @@ +"""Auth adapter for MotherDuck (token → driver kwarg).""" +from __future__ import annotations +import typing as t + + +def token(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + """Map TokenAuthProfile → MotherDuck driver kwarg ``token``.""" + return {**base, "token": auth.TOKEN.get_secret_value()} diff --git a/src/mountainash_data/core/settings/adapters/mssql.py b/src/mountainash_data/core/settings/adapters/mssql.py index da630ec..b9affcc 100644 --- a/src/mountainash_data/core/settings/adapters/mssql.py +++ b/src/mountainash_data/core/settings/adapters/mssql.py @@ -1,56 +1,47 @@ -"""MSSQL adapter: auth dispatch, instance-name folding, encryption keys.""" - +"""MSSQL adapters.""" from __future__ import annotations - import typing as t -from mountainash_settings.auth import ( - AzureADAuth, - PasswordAuth, - WindowsAuth, -) - -if t.TYPE_CHECKING: - from mountainash_data.core.settings.mssql import MSSQLAuthSettings - -def build_driver_kwargs(profile: "MSSQLAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_kwargs() - - # Instance name → host\instance +def host_fold(profile: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = dict(base) if profile.INSTANCE_NAME: - kwargs["host"] = f"{kwargs['host']}\\{profile.INSTANCE_NAME}" - - # Encryption flags + out["host"] = f"{out['host']}\\{profile.INSTANCE_NAME}" if profile.ENCRYPTION is not None: - kwargs["encrypt"] = str(profile.ENCRYPTION) + out["encrypt"] = str(profile.ENCRYPTION) if profile.TRUST_SERVER_CERTIFICATE: - kwargs["trust_server_certificate"] = "yes" + out["trust_server_certificate"] = "yes" if profile.MARS_ENABLED: - kwargs["mars_connection"] = "yes" - - # Auth dispatch - auth = profile.auth - if isinstance(auth, PasswordAuth): - kwargs["user"] = auth.username - kwargs["password"] = auth.password.get_secret_value() - elif isinstance(auth, WindowsAuth): - kwargs["trusted_connection"] = "yes" - if auth.domain and auth.username: - kwargs["user"] = f"{auth.domain}\\{auth.username}" - elif auth.username: - kwargs["user"] = auth.username - elif isinstance(auth, AzureADAuth): - if auth.managed_identity: - kwargs["authentication"] = "ActiveDirectoryMsi" - if auth.msi_endpoint: - kwargs["msi_endpoint"] = auth.msi_endpoint - else: - kwargs["authentication"] = "ActiveDirectoryServicePrincipal" - if auth.client_id: - kwargs["user_id"] = auth.client_id - if auth.client_secret: - kwargs["password"] = auth.client_secret.get_secret_value() - if auth.tenant_id: - kwargs["tenant_id"] = auth.tenant_id - return kwargs + out["mars_connection"] = "yes" + return out + + +# mirrors sql.userpass intentionally — mssql also uses user/password keys +def password(auth, base): + return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} + + +def windows(auth, base): + out = {**base, "trusted_connection": "yes"} + if auth.DOMAIN is not None and auth.USERNAME is not None: + out["user"] = f"{auth.DOMAIN}\\{auth.USERNAME}" + elif auth.USERNAME is not None: + out["user"] = auth.USERNAME + return out + + +def azure_ad(auth, base): + out = dict(base) + if auth.MANAGED_IDENTITY: + out["authentication"] = "ActiveDirectoryMsi" + if auth.MSI_ENDPOINT: + out["msi_endpoint"] = auth.MSI_ENDPOINT + else: + out["authentication"] = "ActiveDirectoryServicePrincipal" + if auth.CLIENT_ID is not None: + out["user_id"] = auth.CLIENT_ID + if auth.CLIENT_SECRET is not None: + out["password"] = auth.CLIENT_SECRET.get_secret_value() + if auth.TENANT_ID is not None: + out["tenant_id"] = auth.TENANT_ID + return out diff --git a/src/mountainash_data/core/settings/adapters/mysql.py b/src/mountainash_data/core/settings/adapters/mysql.py index 0be23d7..3845fed 100644 --- a/src/mountainash_data/core/settings/adapters/mysql.py +++ b/src/mountainash_data/core/settings/adapters/mysql.py @@ -1,31 +1,20 @@ -"""Adapter that assembles mysqlclient's ssl={} dict.""" - +"""MySQL config-shaping adapter.""" from __future__ import annotations - import typing as t -if t.TYPE_CHECKING: - from mountainash_data.core.settings.mysql import MySQLAuthSettings - - -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_kwargs() - kwargs.update(profile._auth_kwargs()) +def ssl_compose(profile: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = dict(base) if profile.SSL_MODE is not None: - kwargs["ssl_mode"] = str(profile.SSL_MODE) - + out["ssl_mode"] = str(profile.SSL_MODE) ssl: dict[str, str] = {} for key, val in { - "ssl-key": profile.SSL_KEY, - "ssl-cert": profile.SSL_CERT, - "ssl-ca": profile.SSL_CA, - "ssl-capath": profile.SSL_CAPATH, + "ssl-key": profile.SSL_KEY, "ssl-cert": profile.SSL_CERT, + "ssl-ca": profile.SSL_CA, "ssl-capath": profile.SSL_CAPATH, "ssl-cipher": profile.SSL_CIPHER, }.items(): if val is not None: ssl[key] = str(val) if ssl: - kwargs["ssl"] = ssl - return kwargs + out["ssl"] = ssl + return out diff --git a/src/mountainash_data/core/settings/adapters/pyiceberg_rest.py b/src/mountainash_data/core/settings/adapters/pyiceberg_rest.py index f5d4ed8..5a4ebd3 100644 --- a/src/mountainash_data/core/settings/adapters/pyiceberg_rest.py +++ b/src/mountainash_data/core/settings/adapters/pyiceberg_rest.py @@ -1,60 +1,15 @@ -"""Adapter prefixing s3.*, rest.sigv4-*, header.* keys.""" - +"""PyIceberg REST adapters.""" from __future__ import annotations - import typing as t -from mountainash_settings.auth import OAuth2Auth, TokenAuth - -if t.TYPE_CHECKING: - from mountainash_data.core.settings.pyiceberg_rest import ( - PyIcebergRestAuthSettings, - ) - -def build_driver_kwargs(profile: "PyIcebergRestAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_kwargs() - - # S3 family - for field, key in [ - ("S3_REGION", "s3.region"), - ("S3_ENDPOINT", "s3.endpoint"), - ("S3_ACCESS_KEY_ID", "s3.access-key-id"), - ]: - val = getattr(profile, field, None) - if val is not None: - kwargs[key] = val - if profile.S3_SECRET_ACCESS_KEY is not None: - kwargs["s3.secret-access-key"] = profile.S3_SECRET_ACCESS_KEY.get_secret_value() - if profile.S3_SESSION_TOKEN is not None: - kwargs["s3.session-token"] = profile.S3_SESSION_TOKEN.get_secret_value() - - # SigV4 - if profile.REST_SIGV4_ENABLED is not None: - kwargs["rest.sigv4-enabled"] = profile.REST_SIGV4_ENABLED - if profile.REST_SIGNING_REGION is not None: - kwargs["rest.signing-region"] = profile.REST_SIGNING_REGION - if profile.REST_SIGNING_NAME is not None: - kwargs["rest.signing-name"] = profile.REST_SIGNING_NAME - - # Headers (dict → header. = v) +def headers_compose(profile: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = dict(base) if profile.HEADERS: for hk, hv in profile.HEADERS.items(): - kwargs[f"header.{hk}"] = hv + out[f"header.{hk}"] = hv + return out + - # Auth - auth = profile.auth - if isinstance(auth, TokenAuth): - kwargs["token"] = auth.token.get_secret_value() - elif isinstance(auth, OAuth2Auth): - if auth.token is not None: - kwargs["token"] = auth.token.get_secret_value() - elif auth.client_id is not None and auth.client_secret is not None: - kwargs["credential"] = ( - f"{auth.client_id}:{auth.client_secret.get_secret_value()}" - ) - if auth.server_uri is not None: - kwargs["oauth2-server-uri"] = auth.server_uri - if auth.scope is not None: - kwargs["scope"] = auth.scope - return kwargs +def token(auth, base): + return {**base, "token": auth.TOKEN.get_secret_value()} diff --git a/src/mountainash_data/core/settings/adapters/pyspark.py b/src/mountainash_data/core/settings/adapters/pyspark.py deleted file mode 100644 index 45ebdd4..0000000 --- a/src/mountainash_data/core/settings/adapters/pyspark.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Adapter emitting dotted spark.* keys from PySpark settings.""" - -from __future__ import annotations - -import typing as t - -if t.TYPE_CHECKING: - from mountainash_data.core.settings.pyspark import PySparkAuthSettings - - -def build_driver_kwargs(profile: "PySparkAuthSettings") -> dict[str, t.Any]: - """Build driver kwargs from PySpark settings. - - Emits dotted spark.* keys as required by SparkSession.builder.config(**kwargs). - """ - kwargs: dict[str, t.Any] = {} - if profile.MODE is not None: - # StrEnum coerces to its string value; raw str passes through unchanged. - # Handles both pydantic-coerced and setattr-bypass construction paths - # (see MountainAshBaseSettings.update_settings_from_dict). - kwargs["mode"] = str(profile.MODE) - if profile.SESSION is not None: - kwargs["session"] = profile.SESSION - if profile.APPLICATION_NAME is not None: - kwargs["spark.app.name"] = profile.APPLICATION_NAME - if profile.SPARK_MASTER is not None: - kwargs["spark.master"] = profile.SPARK_MASTER - if profile.WAREHOUSE_DIR is not None: - kwargs["spark.sql.warehouse.dir"] = profile.WAREHOUSE_DIR - if profile.PARTITIONS is not None: - kwargs["spark.sql.shuffle.partitions"] = profile.PARTITIONS - # NoAuth is the only accepted mode; nothing else to emit. - return kwargs diff --git a/src/mountainash_data/core/settings/adapters/redshift.py b/src/mountainash_data/core/settings/adapters/redshift.py index 2663d21..ef194f2 100644 --- a/src/mountainash_data/core/settings/adapters/redshift.py +++ b/src/mountainash_data/core/settings/adapters/redshift.py @@ -1,32 +1,22 @@ -"""Redshift adapter: endpoint resolution hook, IAM/password routing.""" - +"""Redshift auth adapter functions.""" from __future__ import annotations - import typing as t -from mountainash_settings.auth import IAMAuth, PasswordAuth - -if t.TYPE_CHECKING: - from mountainash_data.core.settings.redshift import RedshiftAuthSettings +def password(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} -def build_driver_kwargs(profile: "RedshiftAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_kwargs() - auth = profile.auth - if isinstance(auth, PasswordAuth): - kwargs["user"] = auth.username - kwargs["password"] = auth.password.get_secret_value() - elif isinstance(auth, IAMAuth): - kwargs["iam"] = True - if auth.role_arn is not None: - kwargs["iam_role_arn"] = auth.role_arn - if auth.access_key_id is not None: - kwargs["aws_access_key_id"] = auth.access_key_id - if auth.secret_access_key is not None: - kwargs["aws_secret_access_key"] = auth.secret_access_key.get_secret_value() - if auth.session_token is not None: - kwargs["aws_session_token"] = auth.session_token.get_secret_value() - if auth.profile_name is not None: - kwargs["profile_name"] = auth.profile_name - return kwargs +def iam(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = {**base, "iam": True} + 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() + if auth.PROFILE_NAME is not None: + out["profile_name"] = auth.PROFILE_NAME + return out diff --git a/src/mountainash_data/core/settings/adapters/registry.py b/src/mountainash_data/core/settings/adapters/registry.py new file mode 100644 index 0000000..0221ca3 --- /dev/null +++ b/src/mountainash_data/core/settings/adapters/registry.py @@ -0,0 +1,54 @@ +"""Data-owned auth dispatch: (provider_type, auth_class) -> adapter fn.""" +from __future__ import annotations +import typing as t + +from mountainash_auth_client import ( + PasswordAuthProfile, JWTAuthProfile, KerberosAuthProfile, + ServiceAccountAuthProfile, IAMAuthProfile, TokenAuthProfile, + OAuth2AuthProfile, CertificateAuthProfile, WindowsAuthProfile, AzureADAuthProfile, +) +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from . import (sql as _sql, trino as _trino, snowflake as _snow, bigquery as _bq, + databricks as _dbx, mssql as _mssql, redshift as _rs, pyiceberg_rest as _ice, + motherduck as _md) + +_AUTH_ADAPTERS: dict[tuple[t.Any, type], t.Callable[[t.Any, dict], dict]] = { + (P.MOTHERDUCK, TokenAuthProfile): _md.token, + (P.TRINO, PasswordAuthProfile): _trino.password, + (P.TRINO, JWTAuthProfile): _trino.jwt, + (P.TRINO, KerberosAuthProfile): _trino.kerberos, + (P.SNOWFLAKE, PasswordAuthProfile): _snow.password, + (P.SNOWFLAKE, TokenAuthProfile): _snow.token, + (P.SNOWFLAKE, OAuth2AuthProfile): _snow.oauth2, + (P.SNOWFLAKE, CertificateAuthProfile): _snow.certificate, + (P.BIGQUERY, ServiceAccountAuthProfile): _bq.service_account, + (P.DATABRICKS, TokenAuthProfile): _dbx.token, + (P.DATABRICKS, PasswordAuthProfile): _dbx.password, + (P.MSSQL, PasswordAuthProfile): _mssql.password, + (P.MSSQL, WindowsAuthProfile): _mssql.windows, + (P.MSSQL, AzureADAuthProfile): _mssql.azure_ad, + (P.REDSHIFT, PasswordAuthProfile): _rs.password, + (P.REDSHIFT, IAMAuthProfile): _rs.iam, + (P.PYICEBERG_REST, TokenAuthProfile): _ice.token, +} +for _p in (P.POSTGRESQL, P.MYSQL, P.CLICKHOUSE, P.MATERIALIZE, P.RISINGWAVE, + P.DRUID, P.SINGLESTOREDB, P.IMPALA, P.EXASOL): + _AUTH_ADAPTERS[(_p, PasswordAuthProfile)] = _sql.userpass + + +def auth_adapter(provider_type: t.Any, auth_class: type[t.Any]) -> t.Callable[[t.Any, dict], dict] | None: + matches = [ + k for k in auth_class.__mro__ + if k is not object and (provider_type, k) in _AUTH_ADAPTERS + ] + if not matches: + return None + winner = matches[0] + ambiguous = [k for k in matches[1:] if not issubclass(winner, k)] + if ambiguous: + raise TypeError( + f"ambiguous auth adapter for {auth_class.__name__} on {provider_type}: " + f"{winner.__name__} vs {[k.__name__ for k in ambiguous]} " + f"(multiply-inherits unrelated registered auth types)" + ) + return _AUTH_ADAPTERS[(provider_type, winner)] diff --git a/src/mountainash_data/core/settings/adapters/snowflake.py b/src/mountainash_data/core/settings/adapters/snowflake.py index b542772..c3e6cfc 100644 --- a/src/mountainash_data/core/settings/adapters/snowflake.py +++ b/src/mountainash_data/core/settings/adapters/snowflake.py @@ -1,51 +1,39 @@ -"""Snowflake adapter: session_parameters, authenticator mapping, cert auth.""" - +"""Snowflake adapters.""" from __future__ import annotations - import typing as t -from mountainash_settings.auth import ( - CertificateAuth, - OAuth2Auth, - PasswordAuth, - TokenAuth, -) -if t.TYPE_CHECKING: - from mountainash_data.core.settings.snowflake import SnowflakeAuthSettings +def session_params(profile: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + out = dict(base) + params: dict[str, t.Any] = {} + if profile.QUERY_TAG is not None: + params["QUERY_TAG"] = profile.QUERY_TAG + if profile.TIMEZONE is not None: + params["TIMEZONE"] = profile.TIMEZONE + if params: + out["session_parameters"] = params + return out -def build_driver_kwargs(profile: "SnowflakeAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_kwargs() +def password(auth, base): + return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} - # Session parameters - session_params: dict[str, t.Any] = {} - if profile.TIMEZONE is not None: - session_params["TIMEZONE"] = profile.TIMEZONE - if profile.QUERY_TAG is not None: - session_params["QUERY_TAG"] = profile.QUERY_TAG - if session_params: - kwargs["session_parameters"] = session_params - - # Auth dispatch - auth = profile.auth - if isinstance(auth, PasswordAuth): - kwargs["user"] = auth.username - kwargs["password"] = auth.password.get_secret_value() - if profile.AUTHENTICATOR is not None: - kwargs["authenticator"] = str(profile.AUTHENTICATOR) - elif isinstance(auth, TokenAuth): - kwargs["authenticator"] = "oauth" - kwargs["token"] = auth.token.get_secret_value() - elif isinstance(auth, OAuth2Auth): - kwargs["authenticator"] = "oauth" - if auth.token is not None: - kwargs["token"] = auth.token.get_secret_value() - elif isinstance(auth, CertificateAuth): - if auth.private_key is not None: - kwargs["private_key"] = auth.private_key.get_secret_value() - if auth.private_key_path is not None: - kwargs["private_key_file"] = str(auth.private_key_path) - if auth.passphrase is not None: - kwargs["private_key_file_pwd"] = auth.passphrase.get_secret_value() - return kwargs + +def token(auth, base): + return {**base, "authenticator": "oauth", "token": auth.TOKEN.get_secret_value()} + + +def oauth2(auth, base): + # token-only: never reads CLIENT_ID/SECRET/SERVER_URI/SCOPE (smell #1) + return {**base, "authenticator": "oauth", "token": auth.TOKEN.get_secret_value()} + + +def certificate(auth, base): + out = dict(base) + if auth.PRIVATE_KEY is not None: + out["private_key"] = auth.PRIVATE_KEY.get_secret_value() + if auth.PRIVATE_KEY_PATH is not None: + out["private_key_file"] = str(auth.PRIVATE_KEY_PATH) + if auth.PASSPHRASE is not None: + out["private_key_file_pwd"] = auth.PASSPHRASE.get_secret_value() + return out diff --git a/src/mountainash_data/core/settings/adapters/sql.py b/src/mountainash_data/core/settings/adapters/sql.py new file mode 100644 index 0000000..f80e184 --- /dev/null +++ b/src/mountainash_data/core/settings/adapters/sql.py @@ -0,0 +1,7 @@ +"""Shared auth adapter for flat user/password SQL backends.""" +from __future__ import annotations +import typing as t + + +def userpass(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + return {**base, "user": auth.USERNAME, "password": auth.PASSWORD.get_secret_value()} diff --git a/src/mountainash_data/core/settings/adapters/trino.py b/src/mountainash_data/core/settings/adapters/trino.py index 075cf72..56bec72 100644 --- a/src/mountainash_data/core/settings/adapters/trino.py +++ b/src/mountainash_data/core/settings/adapters/trino.py @@ -1,44 +1,19 @@ -"""Adapter translating AuthSpec → trino.auth.Authentication wrappers.""" - +"""Trino auth adapter functions.""" from __future__ import annotations - import typing as t -from mountainash_settings.auth import ( - JWTAuth, - KerberosAuth, - NoAuth, - PasswordAuth, -) - -if t.TYPE_CHECKING: - from mountainash_data.core.settings.trino import TrinoAuthSettings +def password(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + from trino.auth import BasicAuthentication + return {**base, "user": auth.USERNAME, + "auth": BasicAuthentication(auth.USERNAME, auth.PASSWORD.get_secret_value())} -def build_driver_kwargs(profile: "TrinoAuthSettings") -> dict[str, t.Any]: - kwargs = profile._default_kwargs() - auth = profile.auth - if isinstance(auth, PasswordAuth): - from trino.auth import BasicAuthentication - kwargs["user"] = auth.username - kwargs["auth"] = BasicAuthentication( - auth.username, auth.password.get_secret_value() - ) - elif isinstance(auth, JWTAuth): - from trino.auth import JWTAuthentication +def jwt(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + from trino.auth import JWTAuthentication + return {**base, "auth": JWTAuthentication(auth.TOKEN.get_secret_value())} - kwargs["auth"] = JWTAuthentication(auth.token.get_secret_value()) - elif isinstance(auth, KerberosAuth): - from trino.auth import KerberosAuthentication - kwargs["auth"] = KerberosAuthentication( - config=None, - service_name=auth.service_name, - principal=auth.principal, - ) - elif isinstance(auth, NoAuth): - pass - else: - raise ValueError(f"trino adapter does not support auth: {type(auth).__name__}") - return kwargs +def kerberos(auth: t.Any, base: dict[str, t.Any]) -> dict[str, t.Any]: + from trino.auth import KerberosAuthentication + return {**base, "auth": KerberosAuthentication(config=None, service_name=auth.SERVICE_NAME, principal=auth.PRINCIPAL)} diff --git a/src/mountainash_data/core/settings/auth/__init__.py b/src/mountainash_data/core/settings/auth/__init__.py deleted file mode 100644 index bad7c42..0000000 --- a/src/mountainash_data/core/settings/auth/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Compatibility shim — re-exports from mountainash_settings.auth. - -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", - "AzureADAuth", - "CertificateAuth", - "IAMAuth", - "JWTAuth", - "KerberosAuth", - "NoAuth", - "OAuth2Auth", - "PasswordAuth", - "ServiceAccountAuth", - "TokenAuth", - "WindowsAuth", - "auth_to_driver_kwargs", -] diff --git a/src/mountainash_data/core/settings/auth/base.py b/src/mountainash_data/core/settings/auth/base.py deleted file mode 100644 index 50bce31..0000000 --- a/src/mountainash_data/core/settings/auth/base.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility shim — re-exports from mountainash_settings.auth.base.""" - -from mountainash_settings.auth.base import AuthSpec - -__all__ = ["AuthSpec"] diff --git a/src/mountainash_data/core/settings/auth/dispatch.py b/src/mountainash_data/core/settings/auth/dispatch.py deleted file mode 100644 index 66c8d36..0000000 --- a/src/mountainash_data/core/settings/auth/dispatch.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Compatibility shim — re-exports from mountainash_settings.auth.dispatch.""" - -from mountainash_settings.auth.dispatch import AUTH_TO_DRIVER_KWARGS, auth_to_driver_kwargs - -__all__ = ["AUTH_TO_DRIVER_KWARGS", "auth_to_driver_kwargs"] diff --git a/src/mountainash_data/core/settings/bigquery.py b/src/mountainash_data/core/settings/bigquery.py index e2582c3..1e692aa 100644 --- a/src/mountainash_data/core/settings/bigquery.py +++ b/src/mountainash_data/core/settings/bigquery.py @@ -12,13 +12,12 @@ from pydantic import field_validator from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import bigquery as _adapter -from mountainash_settings.auth import NoAuth, ServiceAccountAuth +from mountainash_auth_client import NoAuthProfile, ServiceAccountAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register -__all__ = ["BigQueryAuthSettings", "BIGQUERY_SPEC"] +__all__ = ["BigQueryBackendProfile", "BIGQUERY_SPEC"] _PROJECT_ID_RE = re.compile(r"^[a-z][a-z0-9-]{4,28}[a-z0-9]$") @@ -37,7 +36,7 @@ def _validate_project_id(value: str) -> str: provider_type=CONST_DB_PROVIDER_TYPE.BIGQUERY, connection_string_scheme="bigquery://", ibis_dialect="bigquery", - auth_modes=[ServiceAccountAuth, NoAuth], + supported_auth=(ServiceAccountAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="PROJECT_ID", type=str, tier="core", driver_key="project_id"), @@ -61,9 +60,8 @@ def _validate_project_id(value: str) -> str: @register -class BigQueryAuthSettings(ConnectionProfile): +class BigQueryBackendProfile(BackendProfile): __spec__ = BIGQUERY_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) @field_validator("PROJECT_ID", check_fields=False) @classmethod diff --git a/src/mountainash_data/core/settings/clickhouse.py b/src/mountainash_data/core/settings/clickhouse.py index cf4d29b..0cb1b89 100644 --- a/src/mountainash_data/core/settings/clickhouse.py +++ b/src/mountainash_data/core/settings/clickhouse.py @@ -10,9 +10,9 @@ import typing as t from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -22,7 +22,7 @@ default_port=9000, connection_string_scheme="clickhouse://", ibis_dialect="clickhouse", - auth_modes=[PasswordAuth, NoAuth], + supported_auth=(PasswordAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=9000, @@ -45,5 +45,5 @@ @register -class ClickHouseAuthSettings(ConnectionProfile): +class ClickHouseBackendProfile(BackendProfile): __spec__ = CLICKHOUSE_SPEC diff --git a/src/mountainash_data/core/settings/databricks.py b/src/mountainash_data/core/settings/databricks.py index 697b25a..cf66887 100644 --- a/src/mountainash_data/core/settings/databricks.py +++ b/src/mountainash_data/core/settings/databricks.py @@ -10,10 +10,9 @@ import typing as t from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import databricks as _adapter -from mountainash_settings.auth import NoAuth, PasswordAuth, TokenAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile, TokenAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -21,7 +20,7 @@ name="databricks", provider_type=CONST_DB_PROVIDER_TYPE.DATABRICKS, ibis_dialect="databricks", - auth_modes=[TokenAuth, PasswordAuth, NoAuth], + supported_auth=(TokenAuthProfile, PasswordAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="SERVER_HOSTNAME", type=str, tier="core", driver_key="server_hostname"), @@ -38,6 +37,5 @@ @register -class DatabricksAuthSettings(ConnectionProfile): +class DatabricksBackendProfile(BackendProfile): __spec__ = DATABRICKS_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) diff --git a/src/mountainash_data/core/settings/descriptor.py b/src/mountainash_data/core/settings/descriptor.py index 0459d70..f9379cc 100644 --- a/src/mountainash_data/core/settings/descriptor.py +++ b/src/mountainash_data/core/settings/descriptor.py @@ -31,12 +31,18 @@ class BackendSpec(ProfileSpec): 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. + supported_auth: Tuple of AuthProfile types this backend accepts. """ default_port: int | None = None connection_string_scheme: str | None = None ibis_dialect: str | None = None rides_on: str | None = None + supported_auth: tuple[type, ...] = () + + def __post_init__(self) -> None: + if not self.supported_auth: + raise ValueError(f"{self.name}: supported_auth must be non-empty") _DEPRECATED = { diff --git a/src/mountainash_data/core/settings/druid.py b/src/mountainash_data/core/settings/druid.py index 51c03dd..b579f36 100644 --- a/src/mountainash_data/core/settings/druid.py +++ b/src/mountainash_data/core/settings/druid.py @@ -10,9 +10,9 @@ from __future__ import annotations from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -22,7 +22,7 @@ default_port=8082, connection_string_scheme="druid://", ibis_dialect="druid", - auth_modes=[PasswordAuth, NoAuth], + supported_auth=(PasswordAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=8082, @@ -36,5 +36,5 @@ @register -class DruidAuthSettings(ConnectionProfile): +class DruidBackendProfile(BackendProfile): __spec__ = DRUID_SPEC diff --git a/src/mountainash_data/core/settings/duckdb.py b/src/mountainash_data/core/settings/duckdb.py index eb74a45..f3efd9b 100644 --- a/src/mountainash_data/core/settings/duckdb.py +++ b/src/mountainash_data/core/settings/duckdb.py @@ -14,12 +14,12 @@ from pydantic import field_validator from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth +from mountainash_auth_client import NoAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register -__all__ = ["DuckDBAuthSettings", "DUCKDB_SPEC"] +__all__ = ["DuckDBBackendProfile", "DUCKDB_SPEC"] _MEMORY_LIMIT_RE = re.compile(r"^(?:\d+(?:\.\d+)?\s*[KMG]i?B|\d+%)$", re.IGNORECASE) @@ -39,7 +39,7 @@ def _validate_memory_limit(value: t.Any) -> t.Any: provider_type=CONST_DB_PROVIDER_TYPE.DUCKDB, connection_string_scheme="duckdb://", ibis_dialect="duckdb", - auth_modes=[NoAuth], + supported_auth=(NoAuthProfile,), parameters=[ ParameterSpec( name="DATABASE", @@ -85,7 +85,7 @@ def _validate_memory_limit(value: t.Any) -> t.Any: @register -class DuckDBAuthSettings(ConnectionProfile): +class DuckDBBackendProfile(BackendProfile): __spec__ = DUCKDB_SPEC @field_validator("MEMORY_LIMIT", check_fields=False) diff --git a/src/mountainash_data/core/settings/exasol.py b/src/mountainash_data/core/settings/exasol.py index f5d4cd9..e7b3f71 100644 --- a/src/mountainash_data/core/settings/exasol.py +++ b/src/mountainash_data/core/settings/exasol.py @@ -8,9 +8,9 @@ from __future__ import annotations from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import PasswordAuth +from mountainash_auth_client import PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -20,7 +20,7 @@ default_port=8563, connection_string_scheme="exasol://", ibis_dialect="exasol", - auth_modes=[PasswordAuth], + supported_auth=(PasswordAuthProfile,), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=8563, @@ -32,5 +32,5 @@ @register -class ExasolAuthSettings(ConnectionProfile): +class ExasolBackendProfile(BackendProfile): __spec__ = EXASOL_SPEC diff --git a/src/mountainash_data/core/settings/impala.py b/src/mountainash_data/core/settings/impala.py index c6b0691..5582358 100644 --- a/src/mountainash_data/core/settings/impala.py +++ b/src/mountainash_data/core/settings/impala.py @@ -12,9 +12,9 @@ from pathlib import Path from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -31,7 +31,7 @@ class ImpalaAuthMechanism(StrEnum): default_port=21050, connection_string_scheme="impala://", ibis_dialect="impala", - auth_modes=[PasswordAuth, NoAuth], + supported_auth=(PasswordAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=21050, @@ -55,5 +55,5 @@ class ImpalaAuthMechanism(StrEnum): @register -class ImpalaAuthSettings(ConnectionProfile): +class ImpalaBackendProfile(BackendProfile): __spec__ = IMPALA_SPEC diff --git a/src/mountainash_data/core/settings/materialize.py b/src/mountainash_data/core/settings/materialize.py index 657f8ca..a345319 100644 --- a/src/mountainash_data/core/settings/materialize.py +++ b/src/mountainash_data/core/settings/materialize.py @@ -10,9 +10,9 @@ import typing as t from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -22,7 +22,7 @@ default_port=6875, connection_string_scheme="materialize://", ibis_dialect="materialize", - auth_modes=[PasswordAuth, NoAuth], + supported_auth=(PasswordAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=6875, @@ -40,5 +40,5 @@ @register -class MaterializeAuthSettings(ConnectionProfile): +class MaterializeBackendProfile(BackendProfile): __spec__ = MATERIALIZE_SPEC diff --git a/src/mountainash_data/core/settings/motherduck.py b/src/mountainash_data/core/settings/motherduck.py index 1f96659..ea12aa9 100644 --- a/src/mountainash_data/core/settings/motherduck.py +++ b/src/mountainash_data/core/settings/motherduck.py @@ -11,12 +11,12 @@ import typing as t from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import TokenAuth +from mountainash_auth_client import TokenAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile, UrlParts from .registry import register -__all__ = ["MotherDuckAuthSettings", "MOTHERDUCK_SPEC"] +__all__ = ["MotherDuckBackendProfile", "MOTHERDUCK_SPEC"] MOTHERDUCK_SPEC = BackendSpec( @@ -25,7 +25,7 @@ connection_string_scheme="duckdb://md:", # md:?motherduck_token=... ibis_dialect="duckdb", rides_on="duckdb", - auth_modes=[TokenAuth], + supported_auth=(TokenAuthProfile,), parameters=[ ParameterSpec(name="DATABASE", type=t.Optional[str], tier="core", default=None), @@ -36,5 +36,12 @@ @register -class MotherDuckAuthSettings(ConnectionProfile): +class MotherDuckBackendProfile(BackendProfile): __spec__ = MOTHERDUCK_SPEC + + def to_url_parts(self) -> UrlParts: + return UrlParts( + scheme="md", + host=None, + database=getattr(self, "DATABASE", None), + ) diff --git a/src/mountainash_data/core/settings/mssql.py b/src/mountainash_data/core/settings/mssql.py index fa944ae..d8a123a 100644 --- a/src/mountainash_data/core/settings/mssql.py +++ b/src/mountainash_data/core/settings/mssql.py @@ -10,10 +10,10 @@ from enum import StrEnum from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import mssql as _adapter -from mountainash_settings.auth import AzureADAuth, PasswordAuth, WindowsAuth +from mountainash_auth_client import AzureADAuthProfile, PasswordAuthProfile, WindowsAuthProfile +from .adapters import mssql as _mssql from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -35,7 +35,7 @@ class MSSQLEncryption(StrEnum): default_port=1433, connection_string_scheme="mssql://", ibis_dialect="mssql", - auth_modes=[PasswordAuth, WindowsAuth, AzureADAuth], + supported_auth=(PasswordAuthProfile, WindowsAuthProfile, AzureADAuthProfile), parameters=[ ParameterSpec( name="HOST", @@ -107,6 +107,6 @@ class MSSQLEncryption(StrEnum): @register -class MSSQLAuthSettings(ConnectionProfile): +class MSSQLBackendProfile(BackendProfile): __spec__ = MSSQL_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) + __adapters__ = {CONST_DB_PROVIDER_TYPE.MSSQL: _mssql.host_fold} diff --git a/src/mountainash_data/core/settings/mysql.py b/src/mountainash_data/core/settings/mysql.py index 4e33ac8..3565283 100644 --- a/src/mountainash_data/core/settings/mysql.py +++ b/src/mountainash_data/core/settings/mysql.py @@ -13,10 +13,10 @@ from pathlib import Path from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import mysql as _adapter -from mountainash_settings.auth import PasswordAuth +from mountainash_auth_client import PasswordAuthProfile +from .adapters import mysql as _mysql from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -34,7 +34,7 @@ class MySQLSSLMode(StrEnum): default_port=3306, connection_string_scheme="mysql://", ibis_dialect="mysql", - auth_modes=[PasswordAuth], + supported_auth=(PasswordAuthProfile,), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=3306, @@ -83,6 +83,6 @@ class MySQLSSLMode(StrEnum): @register -class MySQLAuthSettings(ConnectionProfile): +class MySQLBackendProfile(BackendProfile): __spec__ = MYSQL_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) + __adapters__ = {CONST_DB_PROVIDER_TYPE.MYSQL: _mysql.ssl_compose} diff --git a/src/mountainash_data/core/settings/postgresql.py b/src/mountainash_data/core/settings/postgresql.py index afef815..d7d5dd5 100644 --- a/src/mountainash_data/core/settings/postgresql.py +++ b/src/mountainash_data/core/settings/postgresql.py @@ -15,9 +15,9 @@ from pydantic import SecretStr from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -69,7 +69,7 @@ def _join_require_auth(v: list[PostgresRequireAuthMethods]) -> str: default_port=5432, connection_string_scheme="postgresql://", ibis_dialect="postgres", - auth_modes=[PasswordAuth, NoAuth], + supported_auth=(PasswordAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="HOSTADDR", type=t.Optional[str], tier="advanced", @@ -157,5 +157,5 @@ def _join_require_auth(v: list[PostgresRequireAuthMethods]) -> str: @register -class PostgreSQLAuthSettings(ConnectionProfile): +class PostgreSQLBackendProfile(BackendProfile): __spec__ = POSTGRESQL_SPEC diff --git a/src/mountainash_data/core/settings/profile.py b/src/mountainash_data/core/settings/profile.py index b0d2855..f275a5b 100644 --- a/src/mountainash_data/core/settings/profile.py +++ b/src/mountainash_data/core/settings/profile.py @@ -1,81 +1,45 @@ -"""ConnectionProfile — database-flavored subclass of Profile. +"""BackendProfile — database-flavored subclass of Profile. -Adds ``to_driver_kwargs()`` and ``to_connection_string()`` on top of the -generic mechanism provided by -:class:`mountainash_settings.profiles.Profile`. +Pure L1 config emitter. Auth is orthogonal — applied by ConnectionFactory, +never here. """ from __future__ import annotations -import typing as t -from urllib.parse import quote +from dataclasses import dataclass, field from mountainash_settings import lookup_class_var from mountainash_settings.profiles import Profile -__all__ = ["ConnectionProfile"] +__all__ = ["BackendProfile", "UrlParts"] -class ConnectionProfile(Profile): - """Database connection settings. +@dataclass(frozen=True) +class UrlParts: + """Credential-free URL skeleton (L1). Every authority component optional.""" + scheme: str + database: str | None = None + host: str | None = None + port: int | None = None + path: str | None = None + query: dict[str, str] = field(default_factory=dict) - Public API: - - :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. - Subclasses set ``__spec__`` (a :class:`BackendSpec`) and - optionally ``__adapter__``. Field installation, auth union, and template - wiring are inherited from :class:`Profile`. - """ - - def to_driver_kwargs(self) -> dict[str, t.Any]: - """Build the final driver kwargs dict. - - 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 = lookup_class_var(type(self), "__adapter__") - if adapter is not None: - return adapter(self) - kwargs = self._default_kwargs() - kwargs.update(self._auth_kwargs()) - return kwargs +class BackendProfile(Profile): + """Database backend CONFIG. Pure L1 emitter — no auth methods. - def to_connection_string(self) -> str: - """Build ``scheme://user:pass@host:port/database`` from the descriptor. + Auth is orthogonal, applied by ConnectionFactory, never here. + """ - 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. - """ + def to_url_parts(self) -> UrlParts: desc = lookup_class_var(type(self), "__spec__") 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"Profile {self.backend!r} has no connection string scheme" - ) - host = getattr(self, "HOST", None) - port = getattr(self, "PORT", None) - database = getattr(self, "DATABASE", None) - url = scheme - auth = getattr(self, "auth", None) - if auth is not None: - username = getattr(auth, "username", None) - if username: - url += quote(str(username), safe="") - pw = getattr(auth, "password", None) - if pw is not None: - url += ":" + quote(pw.get_secret_value(), safe="") - url += "@" - if host is not None: - url += str(host) - if port is not None: - url += f":{port}" - if database is not None: - url += f"/{database}" - return url + raise NotImplementedError(f"Profile {self.backend!r} has no URL form") + scheme = scheme.removesuffix("://").removesuffix(":") + return UrlParts( + scheme=scheme, + host=getattr(self, "HOST", None), + port=getattr(self, "PORT", None), + database=getattr(self, "DATABASE", None), + ) diff --git a/src/mountainash_data/core/settings/pyiceberg_rest.py b/src/mountainash_data/core/settings/pyiceberg_rest.py index 169d2f8..c72f754 100644 --- a/src/mountainash_data/core/settings/pyiceberg_rest.py +++ b/src/mountainash_data/core/settings/pyiceberg_rest.py @@ -11,10 +11,10 @@ from pydantic import SecretStr from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import pyiceberg_rest as _adapter -from mountainash_settings.auth import OAuth2Auth, TokenAuth +from mountainash_auth_client import TokenAuthProfile +from .adapters import pyiceberg_rest as _ice from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -22,7 +22,7 @@ name="pyiceberg_rest", provider_type=CONST_DB_PROVIDER_TYPE.PYICEBERG_REST, connection_string_scheme=None, # uri= kwarg, not URL form - auth_modes=[TokenAuth, OAuth2Auth], + supported_auth=(TokenAuthProfile,), parameters=[ ParameterSpec(name="CATALOG_NAME", type=str, tier="core", driver_key="name"), @@ -32,25 +32,32 @@ default=None, driver_key="warehouse"), ParameterSpec(name="VERIFY_SSL", type=bool, tier="advanced", default=True, driver_key="verify-ssl"), - # S3 family (adapter emits dotted keys) + # S3 family (driver_key emits dotted keys directly) ParameterSpec(name="S3_REGION", type=t.Optional[str], tier="advanced", - default=None), + default=None, driver_key="s3.region"), ParameterSpec(name="S3_ENDPOINT", type=t.Optional[str], - tier="advanced", default=None), + tier="advanced", default=None, driver_key="s3.endpoint"), ParameterSpec(name="S3_ACCESS_KEY_ID", type=t.Optional[str], - tier="advanced", default=None), + tier="advanced", default=None, + driver_key="s3.access-key-id"), ParameterSpec(name="S3_SECRET_ACCESS_KEY", type=t.Optional[SecretStr], tier="advanced", - default=None), + default=None, secret=True, + driver_key="s3.secret-access-key"), ParameterSpec(name="S3_SESSION_TOKEN", type=t.Optional[SecretStr], - tier="advanced", default=None), + tier="advanced", default=None, secret=True, + driver_key="s3.session-token"), # SigV4 ParameterSpec(name="REST_SIGV4_ENABLED", type=t.Optional[bool], - tier="advanced", default=None), + tier="advanced", default=None, + driver_key="rest.sigv4-enabled"), ParameterSpec(name="REST_SIGNING_REGION", type=t.Optional[str], - tier="advanced", default=None), + tier="advanced", default=None, + driver_key="rest.signing-region"), ParameterSpec(name="REST_SIGNING_NAME", type=t.Optional[str], - tier="advanced", default=None), + tier="advanced", default=None, + driver_key="rest.signing-name"), + # HEADERS: no driver_key — adapter expands to header. = v ParameterSpec(name="HEADERS", type=t.Optional[dict[str, str]], tier="advanced", default=None), ], @@ -58,6 +65,6 @@ @register -class PyIcebergRestAuthSettings(ConnectionProfile): +class PyIcebergRestBackendProfile(BackendProfile): __spec__ = PYICEBERG_REST_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) + __adapters__ = {CONST_DB_PROVIDER_TYPE.PYICEBERG_REST: _ice.headers_compose} diff --git a/src/mountainash_data/core/settings/pyspark.py b/src/mountainash_data/core/settings/pyspark.py index 4afdc64..295c62b 100644 --- a/src/mountainash_data/core/settings/pyspark.py +++ b/src/mountainash_data/core/settings/pyspark.py @@ -14,13 +14,12 @@ from enum import StrEnum from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import pyspark as _adapter -from mountainash_settings.auth import NoAuth +from mountainash_auth_client import NoAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register -__all__ = ["PySparkAuthSettings", "PySparkMode", "PYSPARK_SPEC"] +__all__ = ["PySparkBackendProfile", "PySparkMode", "PYSPARK_SPEC"] class PySparkMode(StrEnum): @@ -33,25 +32,24 @@ class PySparkMode(StrEnum): provider_type=CONST_DB_PROVIDER_TYPE.PYSPARK, connection_string_scheme=None, # SparkSession, not URL ibis_dialect="pyspark", - auth_modes=[NoAuth], + supported_auth=(NoAuthProfile,), parameters=[ ParameterSpec(name="SESSION", type=t.Optional[t.Any], tier="core", - default=None), + default=None, driver_key="session"), ParameterSpec(name="MODE", type=PySparkMode, tier="core", - default=PySparkMode.BATCH), + default=PySparkMode.BATCH, driver_key="mode"), ParameterSpec(name="SPARK_MASTER", type=t.Optional[str], tier="advanced", - default=None), + default=None, driver_key="spark.master"), ParameterSpec(name="APPLICATION_NAME", type=t.Optional[str], tier="advanced", - default=None), + default=None, driver_key="spark.app.name"), ParameterSpec(name="WAREHOUSE_DIR", type=t.Optional[str], tier="advanced", - default=None), + default=None, driver_key="spark.sql.warehouse.dir"), ParameterSpec(name="PARTITIONS", type=t.Optional[int], tier="advanced", - default=None), + default=None, driver_key="spark.sql.shuffle.partitions"), ], ) @register -class PySparkAuthSettings(ConnectionProfile): +class PySparkBackendProfile(BackendProfile): __spec__ = PYSPARK_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) diff --git a/src/mountainash_data/core/settings/redshift.py b/src/mountainash_data/core/settings/redshift.py index fd4363e..f02e19d 100644 --- a/src/mountainash_data/core/settings/redshift.py +++ b/src/mountainash_data/core/settings/redshift.py @@ -14,10 +14,9 @@ from pydantic import field_validator from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import redshift as _adapter -from mountainash_settings.auth import IAMAuth, PasswordAuth +from mountainash_auth_client import IAMAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -41,7 +40,7 @@ class RedshiftSSLMode(StrEnum): connection_string_scheme="redshift://", ibis_dialect="postgres", rides_on="postgres", - auth_modes=[PasswordAuth, IAMAuth], + supported_auth=(PasswordAuthProfile, IAMAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=5439, @@ -68,9 +67,8 @@ class RedshiftSSLMode(StrEnum): @register -class RedshiftAuthSettings(ConnectionProfile): +class RedshiftBackendProfile(BackendProfile): __spec__ = REDSHIFT_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) @field_validator("REGION", check_fields=False) @classmethod diff --git a/src/mountainash_data/core/settings/registry.py b/src/mountainash_data/core/settings/registry.py index 98eb415..9b45260 100644 --- a/src/mountainash_data/core/settings/registry.py +++ b/src/mountainash_data/core/settings/registry.py @@ -13,7 +13,7 @@ from mountainash_settings.profiles import Registry from .descriptor import BackendSpec -from .profile import ConnectionProfile +from .profile import BackendProfile __all__ = [ "DATABASES_REGISTRY", @@ -28,7 +28,7 @@ DATABASES_REGISTRY = Registry( "databases", spec_type=BackendSpec, - profile_type=ConnectionProfile, + profile_type=BackendProfile, ) register = DATABASES_REGISTRY.decorator() @@ -38,7 +38,7 @@ def get_descriptor(name: str) -> BackendSpec: return DATABASES_REGISTRY.get_descriptor(name) -def get_settings_class(name: str) -> type["ConnectionProfile"]: +def get_settings_class(name: str) -> type["BackendProfile"]: return DATABASES_REGISTRY.get_settings_class(name) # type: ignore[return-value] diff --git a/src/mountainash_data/core/settings/risingwave.py b/src/mountainash_data/core/settings/risingwave.py index c3f30b0..ded52f3 100644 --- a/src/mountainash_data/core/settings/risingwave.py +++ b/src/mountainash_data/core/settings/risingwave.py @@ -10,9 +10,9 @@ import typing as t from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -22,7 +22,7 @@ default_port=5432, connection_string_scheme="risingwave://", ibis_dialect="risingwave", - auth_modes=[PasswordAuth, NoAuth], + supported_auth=(PasswordAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=5432, @@ -36,5 +36,5 @@ @register -class RisingWaveAuthSettings(ConnectionProfile): +class RisingWaveBackendProfile(BackendProfile): __spec__ = RISINGWAVE_SPEC diff --git a/src/mountainash_data/core/settings/singlestoredb.py b/src/mountainash_data/core/settings/singlestoredb.py index 3bafde0..91fe51f 100644 --- a/src/mountainash_data/core/settings/singlestoredb.py +++ b/src/mountainash_data/core/settings/singlestoredb.py @@ -11,9 +11,9 @@ from enum import StrEnum from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -29,7 +29,7 @@ class SingleStoreDriver(StrEnum): default_port=3306, connection_string_scheme="singlestoredb://", ibis_dialect="singlestoredb", - auth_modes=[PasswordAuth, NoAuth], + supported_auth=(PasswordAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=3306, @@ -47,5 +47,5 @@ class SingleStoreDriver(StrEnum): @register -class SingleStoreDBAuthSettings(ConnectionProfile): +class SingleStoreDBBackendProfile(BackendProfile): __spec__ = SINGLESTOREDB_SPEC diff --git a/src/mountainash_data/core/settings/snowflake.py b/src/mountainash_data/core/settings/snowflake.py index 32bebb4..66ad373 100644 --- a/src/mountainash_data/core/settings/snowflake.py +++ b/src/mountainash_data/core/settings/snowflake.py @@ -10,10 +10,10 @@ from enum import StrEnum from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import snowflake as _adapter -from mountainash_settings.auth import CertificateAuth, OAuth2Auth, PasswordAuth, TokenAuth +from mountainash_auth_client import CertificateAuthProfile, OAuth2AuthProfile, PasswordAuthProfile, TokenAuthProfile +from .adapters import snowflake as _snow from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -30,7 +30,7 @@ class SnowflakeAuthenticator(StrEnum): provider_type=CONST_DB_PROVIDER_TYPE.SNOWFLAKE, connection_string_scheme="snowflake://", ibis_dialect="snowflake", - auth_modes=[PasswordAuth, OAuth2Auth, CertificateAuth, TokenAuth], + supported_auth=(PasswordAuthProfile, OAuth2AuthProfile, CertificateAuthProfile, TokenAuthProfile), parameters=[ ParameterSpec(name="ACCOUNT", type=str, tier="core", driver_key="account"), @@ -44,7 +44,8 @@ class SnowflakeAuthenticator(StrEnum): default=None, driver_key="role"), ParameterSpec(name="AUTHENTICATOR", type=t.Optional[SnowflakeAuthenticator], tier="core", - default=None), + default=None, driver_key="authenticator", + transform=lambda p: str(p)), ParameterSpec(name="CONNECTION_NAME", type=t.Optional[str], tier="core", default=None, driver_key="connection_name"), ParameterSpec(name="TIMEZONE", type=t.Optional[str], tier="advanced", @@ -68,6 +69,6 @@ class SnowflakeAuthenticator(StrEnum): @register -class SnowflakeAuthSettings(ConnectionProfile): +class SnowflakeBackendProfile(BackendProfile): __spec__ = SNOWFLAKE_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) + __adapters__ = {CONST_DB_PROVIDER_TYPE.SNOWFLAKE: _snow.session_params} diff --git a/src/mountainash_data/core/settings/sqlite.py b/src/mountainash_data/core/settings/sqlite.py index dec4f0a..7790d90 100644 --- a/src/mountainash_data/core/settings/sqlite.py +++ b/src/mountainash_data/core/settings/sqlite.py @@ -10,12 +10,12 @@ import typing as t from ..constants import CONST_DB_PROVIDER_TYPE -from mountainash_settings.auth import NoAuth +from mountainash_auth_client import NoAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register -__all__ = ["SQLiteAuthSettings", "SQLITE_SPEC"] +__all__ = ["SQLiteBackendProfile", "SQLITE_SPEC"] SQLITE_SPEC = BackendSpec( @@ -23,7 +23,7 @@ provider_type=CONST_DB_PROVIDER_TYPE.SQLITE, connection_string_scheme="sqlite://", ibis_dialect="sqlite", - auth_modes=[NoAuth], + supported_auth=(NoAuthProfile,), parameters=[ ParameterSpec( name="DATABASE", @@ -46,5 +46,5 @@ @register -class SQLiteAuthSettings(ConnectionProfile): +class SQLiteBackendProfile(BackendProfile): __spec__ = SQLITE_SPEC diff --git a/src/mountainash_data/core/settings/trino.py b/src/mountainash_data/core/settings/trino.py index f663d59..634fb47 100644 --- a/src/mountainash_data/core/settings/trino.py +++ b/src/mountainash_data/core/settings/trino.py @@ -11,10 +11,9 @@ from pathlib import Path from ..constants import CONST_DB_PROVIDER_TYPE -from .adapters import trino as _adapter -from mountainash_settings.auth import JWTAuth, KerberosAuth, NoAuth, PasswordAuth +from mountainash_auth_client import JWTAuthProfile, KerberosAuthProfile, NoAuthProfile, PasswordAuthProfile from .descriptor import BackendSpec, ParameterSpec -from .profile import ConnectionProfile +from .profile import BackendProfile from .registry import register @@ -24,7 +23,7 @@ default_port=8080, connection_string_scheme="trino://", ibis_dialect="trino", - auth_modes=[PasswordAuth, JWTAuth, KerberosAuth, NoAuth], + supported_auth=(PasswordAuthProfile, JWTAuthProfile, KerberosAuthProfile, NoAuthProfile), parameters=[ ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), ParameterSpec(name="PORT", type=int, tier="core", default=8080, @@ -76,6 +75,5 @@ @register -class TrinoAuthSettings(ConnectionProfile): +class TrinoBackendProfile(BackendProfile): __spec__ = TRINO_SPEC - __adapter__ = staticmethod(_adapter.build_driver_kwargs) diff --git a/tests/fixtures/settings_fixtures.py b/tests/fixtures/settings_fixtures.py index 271a91b..ee5a877 100644 --- a/tests/fixtures/settings_fixtures.py +++ b/tests/fixtures/settings_fixtures.py @@ -2,11 +2,8 @@ import pytest from mountainash_data.core.settings import ( - SQLiteAuthSettings, - DuckDBAuthSettings, - PostgreSQLAuthSettings, - ConnectionProfile, - NoAuth, + SQLiteBackendProfile, + DuckDBBackendProfile, ) from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE from mountainash_settings import SettingsParameters @@ -16,8 +13,8 @@ def sqlite_settings_params(temp_sqlite_db): """Create SQLite settings parameters for testing.""" return SettingsParameters.create( - settings_class=SQLiteAuthSettings, - kwargs={"DATABASE": str(temp_sqlite_db), "auth": NoAuth()} + settings_class=SQLiteBackendProfile, + kwargs={"DATABASE": str(temp_sqlite_db)} ) @@ -25,8 +22,8 @@ def sqlite_settings_params(temp_sqlite_db): def sqlite_memory_settings_params(): """Create SQLite in-memory settings parameters for testing.""" return SettingsParameters.create( - settings_class=SQLiteAuthSettings, - kwargs={"DATABASE": ":memory:", "auth": NoAuth()} + settings_class=SQLiteBackendProfile, + kwargs={"DATABASE": ":memory:"} ) @@ -34,8 +31,8 @@ def sqlite_memory_settings_params(): def duckdb_settings_params(): """Create DuckDB settings parameters for testing.""" return SettingsParameters.create( - settings_class=DuckDBAuthSettings, - kwargs={"DATABASE": ":memory:", "auth": NoAuth()} + settings_class=DuckDBBackendProfile, + kwargs={"DATABASE": ":memory:"} ) @@ -43,14 +40,14 @@ def duckdb_settings_params(): def duckdb_file_settings_params(temp_duckdb_db): """Create DuckDB file-based settings parameters for testing.""" return SettingsParameters.create( - settings_class=DuckDBAuthSettings, - kwargs={"DATABASE": str(temp_duckdb_db), "auth": NoAuth()} + settings_class=DuckDBBackendProfile, + kwargs={"DATABASE": str(temp_duckdb_db)} ) @pytest.fixture(params=[ - SQLiteAuthSettings, - DuckDBAuthSettings, + SQLiteBackendProfile, + DuckDBBackendProfile, ]) def backend_settings_class(request): """Parametrized fixture providing all available backend settings classes.""" @@ -58,8 +55,8 @@ def backend_settings_class(request): @pytest.fixture(params=[ - (CONST_DB_PROVIDER_TYPE.SQLITE, SQLiteAuthSettings, ":memory:"), - (CONST_DB_PROVIDER_TYPE.DUCKDB, DuckDBAuthSettings, ":memory:"), + (CONST_DB_PROVIDER_TYPE.SQLITE, SQLiteBackendProfile, ":memory:"), + (CONST_DB_PROVIDER_TYPE.DUCKDB, DuckDBBackendProfile, ":memory:"), ]) def backend_config(request): """Parametrized fixture providing backend configuration tuples. @@ -76,8 +73,8 @@ def settings_factory_helper(): def _create_settings(backend_type, **kwargs): """Create settings for a given backend type.""" settings_map = { - CONST_DB_PROVIDER_TYPE.SQLITE: SQLiteAuthSettings, - CONST_DB_PROVIDER_TYPE.DUCKDB: DuckDBAuthSettings, + CONST_DB_PROVIDER_TYPE.SQLITE: SQLiteBackendProfile, + CONST_DB_PROVIDER_TYPE.DUCKDB: DuckDBBackendProfile, } settings_class = settings_map.get(backend_type) @@ -88,10 +85,6 @@ def _create_settings(backend_type, **kwargs): if "DATABASE" not in kwargs: kwargs["DATABASE"] = ":memory:" - # Set default auth if not provided - if "auth" not in kwargs: - kwargs["auth"] = NoAuth() - return SettingsParameters.create( settings_class=settings_class, kwargs=kwargs diff --git a/tests/test_integration/test_end_to_end_workflows.py b/tests/test_integration/test_end_to_end_workflows.py index 3f7c718..48cb1cb 100644 --- a/tests/test_integration/test_end_to_end_workflows.py +++ b/tests/test_integration/test_end_to_end_workflows.py @@ -2,8 +2,9 @@ import pytest import polars as pl +from mountainash_auth_client import NoAuthProfile from mountainash_data.backends.ibis.backend import IbisBackend -from mountainash_data.core.settings import DuckDBAuthSettings +from mountainash_data.core.settings import DuckDBBackendProfile from mountainash_settings import SettingsParameters @@ -29,11 +30,9 @@ def test_sqlite_url_workflow(self, tmp_path): def test_duckdb_settings_workflow(self): """Full workflow from SettingsParameters.""" - from mountainash_data.core.settings import NoAuth params = SettingsParameters.create( - settings_class=DuckDBAuthSettings, + settings_class=DuckDBBackendProfile, DATABASE=":memory:", - auth=NoAuth(), ) with IbisBackend(params) as backend: backend.create_table("t", {"id": [1, 2]}) diff --git a/tests/test_unit/backends/ibis/test_backend.py b/tests/test_unit/backends/ibis/test_backend.py index 458c9ba..cfe9bf7 100644 --- a/tests/test_unit/backends/ibis/test_backend.py +++ b/tests/test_unit/backends/ibis/test_backend.py @@ -59,35 +59,54 @@ def test_unknown_url_scheme_raises(): def test_settings_path_sqlite(): """Construct IbisBackend from SQLite SettingsParameters and connect.""" + from mountainash_auth_client import NoAuthProfile from mountainash_settings import SettingsParameters - from mountainash_data.core.settings import SQLiteAuthSettings, NoAuth + from mountainash_data.core.settings import SQLiteBackendProfile params = SettingsParameters.create( - settings_class=SQLiteAuthSettings, + settings_class=SQLiteBackendProfile, DATABASE=":memory:", - auth=NoAuth(), ) backend = IbisBackend(params) assert backend.dialect == "sqlite" - backend.connect() + backend.connect(auth_profile=NoAuthProfile()) tables = backend.list_tables() assert isinstance(tables, list) backend.close() +def test_settings_path_sqlite_with_auth_profile(): + """Settings path threads auth through build_driver_kwargs via connect(auth_profile=...).""" + from mountainash_auth_client import NoAuthProfile + from mountainash_settings import SettingsParameters + from mountainash_data.core.settings import SQLiteBackendProfile + + params = SettingsParameters.create( + settings_class=SQLiteBackendProfile, + DATABASE=":memory:", + ) + backend = IbisBackend(params) + assert backend.dialect == "sqlite" + # auth_profile=None normalises to NoAuth, which is supported for sqlite + result = backend.connect(auth_profile=NoAuthProfile()) + assert result is backend + assert isinstance(backend.list_tables(), list) + backend.close() + + def test_settings_path_duckdb_empty_extensions(): """DuckDB settings with default EXTENSIONS=[] must not crash ibis.""" + from mountainash_auth_client import NoAuthProfile from mountainash_settings import SettingsParameters - from mountainash_data.core.settings import DuckDBAuthSettings, NoAuth + from mountainash_data.core.settings import DuckDBBackendProfile params = SettingsParameters.create( - settings_class=DuckDBAuthSettings, + settings_class=DuckDBBackendProfile, DATABASE=":memory:", - auth=NoAuth(), ) backend = IbisBackend(params) assert backend.dialect == "duckdb" - backend.connect() + backend.connect(auth_profile=NoAuthProfile()) backend.close() diff --git a/tests/test_unit/backends/ibis/test_backend_auth.py b/tests/test_unit/backends/ibis/test_backend_auth.py new file mode 100644 index 0000000..904e6a7 --- /dev/null +++ b/tests/test_unit/backends/ibis/test_backend_auth.py @@ -0,0 +1,29 @@ +import pytest +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile +from mountainash_data.backends.ibis.backend import IbisBackend + + +def test_sqlite_dialect_connect_noauth(tmp_path): + be = IbisBackend(dialect="sqlite", database=str(tmp_path / "t.db")).connect(auth_profile=NoAuthProfile()) + assert be is not None + + +def test_dialect_path_applies_password(monkeypatch): + # direct-dialect + explicit auth: auth adapter must run for the dialect's provider. + seen = {} + import mountainash_data.backends.ibis.backend as mod + def fake_apply(pt, base, auth): + seen["pt"], seen["auth"] = pt, auth + return {**base, "user": auth.USERNAME} + monkeypatch.setattr(mod, "apply_auth_adapter", fake_apply) + monkeypatch.setattr(mod, "provider_for_dialect", lambda d: "PG") + IbisBackend(dialect="postgres", host="h", database="db")._resolve_dialect_auth( + PasswordAuthProfile(USERNAME="u", PASSWORD="p") + ) + assert seen["pt"] == "PG" and seen["auth"].USERNAME == "u" + + +def test_url_and_explicit_auth_conflict_raises(): + with pytest.raises(ValueError, match="both"): + IbisBackend("postgresql://u:p@host/db").connect( + auth_profile=PasswordAuthProfile(USERNAME="x", PASSWORD="y")) diff --git a/tests/test_unit/backends/iceberg/test_iceberg_auth.py b/tests/test_unit/backends/iceberg/test_iceberg_auth.py new file mode 100644 index 0000000..421add4 --- /dev/null +++ b/tests/test_unit/backends/iceberg/test_iceberg_auth.py @@ -0,0 +1,40 @@ +import pytest + +pytest.importorskip("pyiceberg", reason="pyiceberg not installed") +pytest.importorskip("mountainash_dataframes", reason="mountainash_dataframes not installed") + +from types import SimpleNamespace # noqa: E402 +from unittest.mock import patch # noqa: E402 + +from mountainash_auth_client import TokenAuthProfile # noqa: E402 +from mountainash_data.backends.iceberg.connection import IcebergConnectionBase # noqa: E402 + + +class _ConcreteIceberg(IcebergConnectionBase): + @property + def catalog_backend(self): + return getattr(self, "_catalog_backend", None) + + +# test double: bypass the remaining ABC methods we don't exercise +_ConcreteIceberg.__abstractmethods__ = frozenset() + + +def test_build_catalog_kwargs_threads_auth_and_merges(): + obj_settings = object() + params = SimpleNamespace( + settings_class=SimpleNamespace(get_settings=lambda settings_parameters: obj_settings) + ) + conn = _ConcreteIceberg.__new__(_ConcreteIceberg) + conn.db_auth_settings_parameters = params + + auth = TokenAuthProfile(TOKEN="T") + with patch( + "mountainash_data.backends.iceberg.connection.build_driver_kwargs", + return_value={"uri": "http://x", "token": "T", "name": "c"}, + ) as bk: + out = conn._build_catalog_kwargs(auth, warehouse="w") + + bk.assert_called_once_with(obj_settings, auth) # profile + auth_profile threaded + assert out["warehouse"] == "w" # explicit kwargs win + assert out["uri"] == "http://x" diff --git a/tests/test_unit/core/factories/__init__.py b/tests/test_unit/core/factories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/core/factories/test_connection_factory.py b/tests/test_unit/core/factories/test_connection_factory.py new file mode 100644 index 0000000..d4d565f --- /dev/null +++ b/tests/test_unit/core/factories/test_connection_factory.py @@ -0,0 +1,94 @@ +import pytest +from dataclasses import dataclass + +from mountainash_auth_client import ( + PasswordAuthProfile, TokenAuthProfile, NoAuthProfile, WindowsAuthProfile, +) +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings.profile import UrlParts +from mountainash_data.core.factories.connection_factory import ( + build_driver_kwargs, build_connection_string, _normalize_and_validate_auth, + apply_auth_adapter, provider_for_dialect, +) + + +@dataclass +class _Spec: + provider_type: object + supported_auth: tuple + name: str = "stub" + + +class _Stub: + def __init__(self, pt, sa, base, url=None): + self.__spec__ = _Spec(pt, sa) + self._base, self._url = base, url or UrlParts(scheme="stub", host="h", port=1, database="db") + @property + def backend(self): return self.__spec__.name + def emit(self, target): + assert target is self.__spec__.provider_type + return dict(self._base) + def to_url_parts(self): return self._url + + +def test_noauth_short_circuits(): + assert build_driver_kwargs(_Stub(P.SQLITE, (NoAuthProfile,), {"database": ":memory:"}), None) == {"database": ":memory:"} + + +def test_password_dispatch(): + out = build_driver_kwargs(_Stub(P.POSTGRESQL, (PasswordAuthProfile, NoAuthProfile), {"host": "h"}), + PasswordAuthProfile(USERNAME="u", PASSWORD="p")) + assert out == {"host": "h", "user": "u", "password": "p"} + + +def test_unsupported_auth_valueerror(): + with pytest.raises(ValueError, match="does not support auth"): + build_driver_kwargs(_Stub(P.SQLITE, (NoAuthProfile,), {}), PasswordAuthProfile(USERNAME="u", PASSWORD="p")) + + +def test_supported_but_no_adapter_fails_closed(): + with pytest.raises(ValueError, match="no auth adapter"): + build_driver_kwargs(_Stub(P.POSTGRESQL, (WindowsAuthProfile,), {"host": "h"}), WindowsAuthProfile(USERNAME="u")) + + +def test_none_normalizes_when_supported(): + assert isinstance(_normalize_and_validate_auth(_Stub(P.SQLITE, (NoAuthProfile,), {}), None), NoAuthProfile) + + +def test_none_rejected_when_noauth_unsupported(): + with pytest.raises(ValueError, match="does not support auth"): + _normalize_and_validate_auth(_Stub(P.MYSQL, (PasswordAuthProfile,), {}), None) + + +def test_apply_auth_adapter_non_profile(): + out = apply_auth_adapter(P.POSTGRESQL, {"host": "h"}, PasswordAuthProfile(USERNAME="u", PASSWORD="p")) + assert out == {"host": "h", "user": "u", "password": "p"} + assert apply_auth_adapter(P.POSTGRESQL, {"host": "h"}, None) == {"host": "h"} + + +def test_provider_for_dialect_collision_resolves_to_base(): + # postgres + duckdb dialects are shared by rider providers; resolve canonically. + assert provider_for_dialect("postgres") is P.POSTGRESQL + assert provider_for_dialect("duckdb") is P.DUCKDB + + +def test_url_password(): + s = _Stub(P.POSTGRESQL, (PasswordAuthProfile,), {}, url=UrlParts(scheme="postgresql", host="db", port=5432, database="app")) + assert build_connection_string(s, PasswordAuthProfile(USERNAME="u", PASSWORD="p@s")) == "postgresql://u:p%40s@db:5432/app" + + +def test_url_token_authority_less(): + s = _Stub(P.MOTHERDUCK, (TokenAuthProfile,), {}, url=UrlParts(scheme="md", database="mydb")) + assert build_connection_string(s, TokenAuthProfile(TOKEN="T")) == "md:mydb?motherduck_token=T" + + +def test_url_noauth_authority_less(): + s = _Stub(P.DUCKDB, (NoAuthProfile,), {}, url=UrlParts(scheme="duckdb", database="my.db")) + assert build_connection_string(s, NoAuthProfile()) == "duckdb:my.db" + + +@pytest.mark.parametrize("auth", [WindowsAuthProfile(USERNAME="u"), TokenAuthProfile(TOKEN="T")]) +def test_url_unsupported_auth_not_implemented(auth): + s = _Stub(P.POSTGRESQL, (type(auth),), {}, url=UrlParts(scheme="postgresql", host="db")) + with pytest.raises(NotImplementedError): + build_connection_string(s, auth) diff --git a/tests/test_unit/core/factories/test_url_consistency.py b/tests/test_unit/core/factories/test_url_consistency.py new file mode 100644 index 0000000..1ec4057 --- /dev/null +++ b/tests/test_unit/core/factories/test_url_consistency.py @@ -0,0 +1,22 @@ +"""URL applier coverage goldens.""" + +import pytest +from mountainash_auth_client import PasswordAuthProfile, TokenAuthProfile +from mountainash_data.core.settings import PostgreSQLBackendProfile, MotherDuckBackendProfile +from mountainash_data.core.factories.connection_factory import build_connection_string + + +def test_postgres_password_url(): + s = PostgreSQLBackendProfile(HOST="db", PORT=5432, DATABASE="app") + assert build_connection_string(s, PasswordAuthProfile(USERNAME="u", PASSWORD="p@s")) == "postgresql://u:p%40s@db:5432/app" + + +def test_motherduck_token_url(): + assert build_connection_string(MotherDuckBackendProfile(DATABASE="mydb"), TokenAuthProfile(TOKEN="T")) == "md:mydb?motherduck_token=T" + + +def test_snowflake_token_url_not_implemented(): + # snowflake supports TokenAuthProfile for kwargs but has no URL form → fail-closed + from mountainash_data.core.settings import SnowflakeBackendProfile + with pytest.raises(NotImplementedError): + build_connection_string(SnowflakeBackendProfile(ACCOUNT="a"), TokenAuthProfile(TOKEN="T")) diff --git a/tests/test_unit/core/settings/adapters/__init__.py b/tests/test_unit/core/settings/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/core/settings/adapters/test_auth_adapters.py b/tests/test_unit/core/settings/adapters/test_auth_adapters.py new file mode 100644 index 0000000..80d4a9b --- /dev/null +++ b/tests/test_unit/core/settings/adapters/test_auth_adapters.py @@ -0,0 +1,92 @@ +import pytest +from mountainash_auth_client import ( + PasswordAuthProfile, TokenAuthProfile, OAuth2AuthProfile, + CertificateAuthProfile, WindowsAuthProfile, AzureADAuthProfile, + IAMAuthProfile, ServiceAccountAuthProfile, +) +from mountainash_data.core.settings.adapters import ( + sql as _sql, snowflake as _snow, mssql as _mssql, + redshift as _rs, databricks as _dbx, pyiceberg_rest as _ice, +) + + +def test_sql_userpass(): + assert _sql.userpass(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), {"host": "h"}) == { + "host": "h", "user": "u", "password": "p"} + + +def test_userpass_no_mutate(): + base = {"host": "h"} + _sql.userpass(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), base) + assert base == {"host": "h"} + + +def test_snowflake_token_oauth(): + assert _snow.token(TokenAuthProfile(TOKEN="t"), {}) == {"authenticator": "oauth", "token": "t"} + + +def test_snowflake_oauth2_token_only(): + assert _snow.oauth2(OAuth2AuthProfile(TOKEN="t"), {}) == {"authenticator": "oauth", "token": "t"} + + +def test_snowflake_password(): + assert _snow.password(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), {}) == {"user": "u", "password": "p"} + + +def test_snowflake_certificate(): + assert _snow.certificate(CertificateAuthProfile(PRIVATE_KEY="KEY", PASSPHRASE="ph"), {}) == { + "private_key": "KEY", "private_key_file_pwd": "ph"} + + +def test_mssql_password(): + assert _mssql.password(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), {}) == {"user": "u", "password": "p"} + + +def test_mssql_windows(): + assert _mssql.windows(WindowsAuthProfile(USERNAME="u", DOMAIN="D"), {}) == { + "trusted_connection": "yes", "user": "D\\u"} + + +def test_mssql_azure_ad_sp(): + assert _mssql.azure_ad(AzureADAuthProfile(CLIENT_ID="cid", CLIENT_SECRET="sec", TENANT_ID="t"), {}) == { + "authentication": "ActiveDirectoryServicePrincipal", "user_id": "cid", + "password": "sec", "tenant_id": "t"} + + +def test_redshift_iam(): + assert _rs.iam(IAMAuthProfile(ROLE_ARN="arn", ACCESS_KEY_ID="ak"), {}) == { + "iam": True, "iam_role_arn": "arn", "aws_access_key_id": "ak"} + + +def test_databricks_token(): + assert _dbx.token(TokenAuthProfile(TOKEN="tok"), {}) == {"access_token": "tok"} + + +def test_pyiceberg_token(): + assert _ice.token(TokenAuthProfile(TOKEN="tok"), {"uri": "u"}) == {"uri": "u", "token": "tok"} + + +def test_trino_password_builds_basic_auth(): + pytest.importorskip("trino") + from trino.auth import BasicAuthentication + from mountainash_data.core.settings.adapters import trino as _trino + out = _trino.password(PasswordAuthProfile(USERNAME="u", PASSWORD="p"), {"host": "h"}) + assert out["host"] == "h" and out["user"] == "u" and isinstance(out["auth"], BasicAuthentication) + + +def test_bigquery_service_account(monkeypatch): + pytest.importorskip("google.oauth2") + from google.oauth2 import service_account as _sa + from mountainash_data.core.settings.adapters import bigquery as _bq + sentinel = object() + monkeypatch.setattr(_sa.Credentials, "from_service_account_info", classmethod(lambda cls, info: sentinel)) + assert _bq.service_account(ServiceAccountAuthProfile(INFO={"k": "v"}), {}) == {"credentials": sentinel} + + +def test_bigquery_service_account_file(monkeypatch): + pytest.importorskip("google.oauth2") + from google.oauth2 import service_account as _sa + from mountainash_data.core.settings.adapters import bigquery as _bq + sentinel = object() + monkeypatch.setattr(_sa.Credentials, "from_service_account_file", classmethod(lambda cls, path: sentinel)) + assert _bq.service_account(ServiceAccountAuthProfile(FILE="/path/sa.json"), {}) == {"credentials": sentinel} diff --git a/tests/test_unit/core/settings/adapters/test_registry.py b/tests/test_unit/core/settings/adapters/test_registry.py new file mode 100644 index 0000000..d28ca84 --- /dev/null +++ b/tests/test_unit/core/settings/adapters/test_registry.py @@ -0,0 +1,47 @@ +import pytest +from mountainash_auth_client import PasswordAuthProfile, TokenAuthProfile, NoAuthProfile +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings.adapters import sql as _sql, snowflake as _snow +from mountainash_data.core.settings.adapters.registry import auth_adapter, _AUTH_ADAPTERS + + +def test_exact_lookup(): + assert auth_adapter(P.SNOWFLAKE, TokenAuthProfile) is _snow.token + + +def test_flat_userpass_shared(): + assert auth_adapter(P.POSTGRESQL, PasswordAuthProfile) is _sql.userpass + + +def test_miss_returns_none(): + assert auth_adapter(P.SQLITE, PasswordAuthProfile) is None + + +def test_noauth_not_in_table(): + assert all(k[1] is not NoAuthProfile for k in _AUTH_ADAPTERS) + + +def test_subclass_resolves_to_base(): + class MyPw(PasswordAuthProfile): pass + assert auth_adapter(P.POSTGRESQL, MyPw) is _sql.userpass + + +def test_specialization_wins(): + fn = lambda a, b: b + class Special(PasswordAuthProfile): pass + _AUTH_ADAPTERS[(P.POSTGRESQL, Special)] = fn + try: + assert auth_adapter(P.POSTGRESQL, Special) is fn + finally: + del _AUTH_ADAPTERS[(P.POSTGRESQL, Special)] + + +def test_sibling_ambiguity_raises(): + fn = lambda a, b: b + _AUTH_ADAPTERS[(P.POSTGRESQL, TokenAuthProfile)] = fn + class Hybrid(PasswordAuthProfile, TokenAuthProfile): pass + try: + with pytest.raises(TypeError, match="ambiguous"): + auth_adapter(P.POSTGRESQL, Hybrid) + finally: + del _AUTH_ADAPTERS[(P.POSTGRESQL, TokenAuthProfile)] diff --git a/tests/test_unit/core/settings/backends/test_bigquery.py b/tests/test_unit/core/settings/backends/test_bigquery.py index 4b7af34..1ac802b 100644 --- a/tests/test_unit/core/settings/backends/test_bigquery.py +++ b/tests/test_unit/core/settings/backends/test_bigquery.py @@ -3,50 +3,22 @@ import pytest -from mountainash_data.core.settings.auth import NoAuth, ServiceAccountAuth -from mountainash_data.core.settings.bigquery import BigQueryAuthSettings +from mountainash_data.core.settings.bigquery import BigQueryBackendProfile @pytest.mark.unit -class TestBigQueryAuthSettings: +class TestBigQueryBackendProfile: def test_partition_column_default(self): """Audit regression: default was None, should be 'PARTITIONTIME'.""" - s = BigQueryAuthSettings(PROJECT_ID="myproj12", auth=NoAuth()) + s = BigQueryBackendProfile(PROJECT_ID="myproj12") assert s.PARTITION_COLUMN == "PARTITIONTIME" - def test_service_account_info_converts_to_credentials(self): - """Audit regression: SA info dict was passed raw; Ibis needs Credentials.""" - pytest.importorskip("google.oauth2") - - # Minimal valid SA info shape - info = { - "type": "service_account", - "project_id": "myproj12", - "private_key_id": "x", - "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", - "client_email": "sa@myproj12.iam.gserviceaccount.com", - "client_id": "1", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token", - } - - s = BigQueryAuthSettings( - PROJECT_ID="myproj12", - auth=ServiceAccountAuth(info=info), - ) - # We can't fully construct credentials without a valid key, so we - # just verify the adapter attempts conversion and emits the key. - try: - kwargs = s.to_driver_kwargs() - assert "credentials" in kwargs - except ValueError: - # google.oauth2 will reject the dummy key — acceptable here, - # the important thing is no raw dict leak. - pass + def test_emit_project_id(self): + s = BigQueryBackendProfile(PROJECT_ID="myproj12") + kwargs = s.emit() + assert kwargs["project_id"] == "myproj12" def test_auth_local_webserver_plumbed(self): """Audit regression: field didn't exist.""" - s = BigQueryAuthSettings( - PROJECT_ID="myproj12", AUTH_LOCAL_WEBSERVER=False, auth=NoAuth(), - ) - assert s.to_driver_kwargs()["auth_local_webserver"] is False + s = BigQueryBackendProfile(PROJECT_ID="myproj12", AUTH_LOCAL_WEBSERVER=False) + assert s.emit()["auth_local_webserver"] is False diff --git a/tests/test_unit/core/settings/backends/test_clickhouse.py b/tests/test_unit/core/settings/backends/test_clickhouse.py index fd1cba2..8585fce 100644 --- a/tests/test_unit/core/settings/backends/test_clickhouse.py +++ b/tests/test_unit/core/settings/backends/test_clickhouse.py @@ -2,21 +2,15 @@ from __future__ import annotations import pytest -from pydantic import SecretStr from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE -from mountainash_data.core.settings.auth import NoAuth, PasswordAuth -from mountainash_data.core.settings.clickhouse import ClickHouseAuthSettings +from mountainash_data.core.settings.clickhouse import ClickHouseBackendProfile @pytest.mark.unit -class TestClickHouseAuthSettings: +class TestClickHouseBackendProfile: def _minimal(self, **extra): - return ClickHouseAuthSettings( - HOST="ch.example.com", - auth=PasswordAuth(username="demo", password=SecretStr("s3cret")), - **extra, - ) + return ClickHouseBackendProfile(HOST="ch.example.com", **extra) def test_provider_type_is_clickhouse(self): s = self._minimal() @@ -38,19 +32,17 @@ def test_secure_true(self): s = self._minimal(SECURE=True) assert s.SECURE is True - def test_no_auth(self): - s = ClickHouseAuthSettings(HOST="ch.example.com", auth=NoAuth()) + def test_minimal_construction(self): + s = ClickHouseBackendProfile(HOST="ch.example.com") assert s.HOST == "ch.example.com" - def test_to_driver_kwargs_plumbs_core_fields(self): + def test_emit_plumbs_core_fields(self): s = self._minimal(PORT=443, DATABASE="pypi", SECURE=True) - kwargs = s.to_driver_kwargs() + kwargs = s.emit() assert kwargs["host"] == "ch.example.com" assert kwargs["port"] == 443 assert kwargs["database"] == "pypi" assert kwargs["secure"] is True - assert kwargs["user"] == "demo" - assert kwargs["password"] == "s3cret" def test_ibis_dialect(self): s = self._minimal() diff --git a/tests/test_unit/core/settings/backends/test_databricks.py b/tests/test_unit/core/settings/backends/test_databricks.py index 58af3ad..add1719 100644 --- a/tests/test_unit/core/settings/backends/test_databricks.py +++ b/tests/test_unit/core/settings/backends/test_databricks.py @@ -2,72 +2,45 @@ from __future__ import annotations import pytest -from pydantic import SecretStr from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE -from mountainash_data.core.settings.auth import NoAuth, PasswordAuth, TokenAuth -from mountainash_data.core.settings.databricks import DatabricksAuthSettings +from mountainash_data.core.settings.databricks import DatabricksBackendProfile @pytest.mark.unit -class TestDatabricksAuthSettings: - def _token(self, **extra): - return DatabricksAuthSettings( +class TestDatabricksBackendProfile: + def _minimal(self, **extra): + return DatabricksBackendProfile( SERVER_HOSTNAME="adb-123.12.azuredatabricks.net", HTTP_PATH="/sql/1.0/warehouses/abc123", - auth=TokenAuth(token=SecretStr("dapi-xyz")), **extra, ) def test_provider_type_is_databricks(self): - s = self._token() + s = self._minimal() assert s.provider_type == CONST_DB_PROVIDER_TYPE.DATABRICKS def test_schema_default(self): - s = self._token() + s = self._minimal() assert s.SCHEMA == "default" def test_use_cloud_fetch_default_false(self): - s = self._token() + s = self._minimal() assert s.USE_CLOUD_FETCH is False - def test_token_auth_kwargs(self): - s = self._token(CATALOG="analytics", SCHEMA="gold") - kwargs = s.to_driver_kwargs() + def test_emit_core_fields(self): + s = self._minimal(CATALOG="analytics", SCHEMA="gold") + kwargs = s.emit() assert kwargs["server_hostname"] == "adb-123.12.azuredatabricks.net" assert kwargs["http_path"] == "/sql/1.0/warehouses/abc123" - assert kwargs["access_token"] == "dapi-xyz" assert kwargs["catalog"] == "analytics" assert kwargs["schema"] == "gold" - assert "username" not in kwargs - assert "password" not in kwargs - - def test_password_auth_kwargs(self): - s = DatabricksAuthSettings( - SERVER_HOSTNAME="adb-123.12.azuredatabricks.net", - HTTP_PATH="/sql/1.0/warehouses/abc123", - auth=PasswordAuth(username="user", password=SecretStr("pass")), - ) - kwargs = s.to_driver_kwargs() - assert kwargs["username"] == "user" - assert kwargs["password"] == "pass" - assert "access_token" not in kwargs - - def test_no_auth(self): - s = DatabricksAuthSettings( - SERVER_HOSTNAME="adb-123.12.azuredatabricks.net", - HTTP_PATH="/sql/1.0/warehouses/abc123", - auth=NoAuth(), - ) - kwargs = s.to_driver_kwargs() - assert "access_token" not in kwargs - assert "username" not in kwargs def test_ibis_dialect(self): - s = self._token() + s = self._minimal() assert s.backend == "databricks" def test_use_cloud_fetch_plumbed(self): - s = self._token(USE_CLOUD_FETCH=True) - kwargs = s.to_driver_kwargs() + s = self._minimal(USE_CLOUD_FETCH=True) + kwargs = s.emit() assert kwargs["use_cloud_fetch"] is True diff --git a/tests/test_unit/core/settings/backends/test_druid.py b/tests/test_unit/core/settings/backends/test_druid.py index d66a044..05115ac 100644 --- a/tests/test_unit/core/settings/backends/test_druid.py +++ b/tests/test_unit/core/settings/backends/test_druid.py @@ -2,21 +2,15 @@ from __future__ import annotations import pytest -from pydantic import SecretStr from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE -from mountainash_data.core.settings.auth import NoAuth, PasswordAuth -from mountainash_data.core.settings.druid import DruidAuthSettings +from mountainash_data.core.settings.druid import DruidBackendProfile @pytest.mark.unit -class TestDruidAuthSettings: +class TestDruidBackendProfile: def _minimal(self, **extra): - return DruidAuthSettings( - HOST="druid.example.com", - auth=NoAuth(), - **extra, - ) + return DruidBackendProfile(HOST="druid.example.com", **extra) def test_provider_type(self): assert self._minimal().provider_type == CONST_DB_PROVIDER_TYPE.DRUID @@ -30,21 +24,12 @@ def test_default_path(self): def test_default_scheme(self): assert self._minimal().SCHEME == "http" - def test_to_driver_kwargs(self): - kwargs = self._minimal(PORT=8888, SCHEME="https").to_driver_kwargs() + def test_emit(self): + kwargs = self._minimal(PORT=8888, SCHEME="https").emit() assert kwargs["host"] == "druid.example.com" assert kwargs["port"] == 8888 assert kwargs["path"] == "/druid/v2/sql" assert kwargs["scheme"] == "https" - def test_with_password_auth(self): - s = DruidAuthSettings( - HOST="druid.example.com", - auth=PasswordAuth(username="admin", password=SecretStr("pass")), - ) - kwargs = s.to_driver_kwargs() - assert kwargs["user"] == "admin" - assert kwargs["password"] == "pass" - def test_ibis_dialect(self): assert self._minimal().backend == "druid" diff --git a/tests/test_unit/core/settings/backends/test_duckdb.py b/tests/test_unit/core/settings/backends/test_duckdb.py index cb036c5..1a3318d 100644 --- a/tests/test_unit/core/settings/backends/test_duckdb.py +++ b/tests/test_unit/core/settings/backends/test_duckdb.py @@ -5,48 +5,43 @@ import pytest from pydantic import ValidationError -from mountainash_data.core.settings.auth import NoAuth -from mountainash_data.core.settings.duckdb import DuckDBAuthSettings +from mountainash_data.core.settings.duckdb import DuckDBBackendProfile @pytest.mark.unit -class TestDuckDBAuthSettings: +class TestDuckDBBackendProfile: def test_default_read_only_is_false(self): """Audit regression: previously defaulted True, mismatched Ibis.""" - s = DuckDBAuthSettings(auth=NoAuth()) + s = DuckDBBackendProfile() assert s.READ_ONLY is False def test_memory_database_default(self): - s = DuckDBAuthSettings(auth=NoAuth()) + s = DuckDBBackendProfile() assert s.DATABASE is None - def test_to_driver_kwargs_default(self): - s = DuckDBAuthSettings(DATABASE=":memory:", auth=NoAuth()) - kwargs = s.to_driver_kwargs() + def test_emit_default(self): + s = DuckDBBackendProfile(DATABASE=":memory:") + kwargs = s.emit() assert kwargs["database"] == ":memory:" assert kwargs["read_only"] is False def test_memory_limit_decimal_accepted(self): """Audit regression: regex previously rejected '1.5GB'.""" - s = DuckDBAuthSettings(DATABASE=":memory:", MEMORY_LIMIT="1.5GB", - auth=NoAuth()) + s = DuckDBBackendProfile(DATABASE=":memory:", MEMORY_LIMIT="1.5GB") assert s.MEMORY_LIMIT == "1.5GB" def test_memory_limit_percent_accepted(self): - s = DuckDBAuthSettings(DATABASE=":memory:", MEMORY_LIMIT="80%", - auth=NoAuth()) + s = DuckDBBackendProfile(DATABASE=":memory:", MEMORY_LIMIT="80%") assert s.MEMORY_LIMIT == "80%" def test_memory_limit_garbage_rejected(self): with pytest.raises(ValidationError): - DuckDBAuthSettings(DATABASE=":memory:", MEMORY_LIMIT="lots", - auth=NoAuth()) + DuckDBBackendProfile(DATABASE=":memory:", MEMORY_LIMIT="lots") def test_extensions_passed_as_top_level_kwarg(self): """Audit regression: extensions was packed inside config dict.""" - s = DuckDBAuthSettings(DATABASE=":memory:", EXTENSIONS=["httpfs"], - auth=NoAuth()) - kwargs = s.to_driver_kwargs() + s = DuckDBBackendProfile(DATABASE=":memory:", EXTENSIONS=["httpfs"]) + kwargs = s.emit() assert kwargs["extensions"] == ["httpfs"] # Must NOT appear inside a nested config dict: assert "config" not in kwargs or "extensions" not in kwargs.get("config", {}) diff --git a/tests/test_unit/core/settings/backends/test_exasol.py b/tests/test_unit/core/settings/backends/test_exasol.py index e048a9b..0ef41e7 100644 --- a/tests/test_unit/core/settings/backends/test_exasol.py +++ b/tests/test_unit/core/settings/backends/test_exasol.py @@ -2,21 +2,15 @@ from __future__ import annotations import pytest -from pydantic import SecretStr from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE -from mountainash_data.core.settings.auth import PasswordAuth -from mountainash_data.core.settings.exasol import ExasolAuthSettings +from mountainash_data.core.settings.exasol import ExasolBackendProfile @pytest.mark.unit -class TestExasolAuthSettings: +class TestExasolBackendProfile: def _minimal(self, **extra): - return ExasolAuthSettings( - HOST="exasol.example.com", - auth=PasswordAuth(username="sys", password=SecretStr("s3cret")), - **extra, - ) + return ExasolBackendProfile(HOST="exasol.example.com", **extra) def test_provider_type(self): assert self._minimal().provider_type == CONST_DB_PROVIDER_TYPE.EXASOL @@ -27,13 +21,11 @@ def test_default_port(self): def test_default_timezone(self): assert self._minimal().TIMEZONE == "UTC" - def test_to_driver_kwargs(self): - kwargs = self._minimal().to_driver_kwargs() + def test_emit(self): + kwargs = self._minimal().emit() assert kwargs["host"] == "exasol.example.com" assert kwargs["port"] == 8563 assert kwargs["timezone"] == "UTC" - assert kwargs["user"] == "sys" - assert kwargs["password"] == "s3cret" def test_ibis_dialect(self): assert self._minimal().backend == "exasol" diff --git a/tests/test_unit/core/settings/backends/test_impala.py b/tests/test_unit/core/settings/backends/test_impala.py index 989dd8b..272339f 100644 --- a/tests/test_unit/core/settings/backends/test_impala.py +++ b/tests/test_unit/core/settings/backends/test_impala.py @@ -2,24 +2,19 @@ from __future__ import annotations import pytest -from pydantic import SecretStr, ValidationError +from pydantic import ValidationError from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE -from mountainash_data.core.settings.auth import NoAuth, PasswordAuth from mountainash_data.core.settings.impala import ( - ImpalaAuthSettings, + ImpalaBackendProfile, ImpalaAuthMechanism, ) @pytest.mark.unit -class TestImpalaAuthSettings: +class TestImpalaBackendProfile: def _minimal(self, **extra): - return ImpalaAuthSettings( - HOST="impala.example.com", - auth=NoAuth(), - **extra, - ) + return ImpalaBackendProfile(HOST="impala.example.com", **extra) def test_provider_type(self): assert self._minimal().provider_type == CONST_DB_PROVIDER_TYPE.IMPALA @@ -39,22 +34,19 @@ def test_auth_mechanism_enum_enforced(self): def test_gssapi_mechanism(self): s = self._minimal(AUTH_MECHANISM=ImpalaAuthMechanism.GSSAPI) - kwargs = s.to_driver_kwargs() + kwargs = s.emit() assert kwargs["auth_mechanism"] == "GSSAPI" - def test_ldap_with_password(self): - s = ImpalaAuthSettings( + def test_ldap_mechanism(self): + s = ImpalaBackendProfile( HOST="impala.example.com", - auth=PasswordAuth(username="user", password=SecretStr("pass")), AUTH_MECHANISM=ImpalaAuthMechanism.LDAP, ) - kwargs = s.to_driver_kwargs() + kwargs = s.emit() assert kwargs["auth_mechanism"] == "LDAP" - assert kwargs["user"] == "user" - assert kwargs["password"] == "pass" - def test_to_driver_kwargs_plumbs_ssl(self): - kwargs = self._minimal(USE_SSL=True).to_driver_kwargs() + def test_emit_plumbs_ssl(self): + kwargs = self._minimal(USE_SSL=True).emit() assert kwargs["use_ssl"] is True def test_ibis_dialect(self): diff --git a/tests/test_unit/core/settings/backends/test_materialize.py b/tests/test_unit/core/settings/backends/test_materialize.py index 77b24bc..42b7ef2 100644 --- a/tests/test_unit/core/settings/backends/test_materialize.py +++ b/tests/test_unit/core/settings/backends/test_materialize.py @@ -2,21 +2,15 @@ from __future__ import annotations import pytest -from pydantic import SecretStr from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE -from mountainash_data.core.settings.auth import NoAuth, PasswordAuth -from mountainash_data.core.settings.materialize import MaterializeAuthSettings +from mountainash_data.core.settings.materialize import MaterializeBackendProfile @pytest.mark.unit -class TestMaterializeAuthSettings: +class TestMaterializeBackendProfile: def _minimal(self, **extra): - return MaterializeAuthSettings( - HOST="materialize.example.com", - auth=PasswordAuth(username="mz", password=SecretStr("s3cret")), - **extra, - ) + return MaterializeBackendProfile(HOST="materialize.example.com", **extra) def test_provider_type(self): assert self._minimal().provider_type == CONST_DB_PROVIDER_TYPE.MATERIALIZE @@ -29,20 +23,18 @@ def test_default_autocommit(self): def test_cluster_param(self): s = self._minimal(CLUSTER="quickstart") - kwargs = s.to_driver_kwargs() + kwargs = s.emit() assert kwargs["cluster"] == "quickstart" - def test_to_driver_kwargs(self): - kwargs = self._minimal(DATABASE="mydb", SCHEMA="public").to_driver_kwargs() + def test_emit(self): + kwargs = self._minimal(DATABASE="mydb", SCHEMA="public").emit() assert kwargs["host"] == "materialize.example.com" assert kwargs["port"] == 6875 assert kwargs["database"] == "mydb" assert kwargs["schema"] == "public" - assert kwargs["user"] == "mz" - assert kwargs["password"] == "s3cret" - def test_no_auth(self): - s = MaterializeAuthSettings(HOST="mz.local", auth=NoAuth()) + def test_minimal_construction(self): + s = MaterializeBackendProfile(HOST="mz.local") assert s.HOST == "mz.local" def test_ibis_dialect(self): diff --git a/tests/test_unit/core/settings/backends/test_motherduck.py b/tests/test_unit/core/settings/backends/test_motherduck.py index 58a5e34..56b8838 100644 --- a/tests/test_unit/core/settings/backends/test_motherduck.py +++ b/tests/test_unit/core/settings/backends/test_motherduck.py @@ -2,29 +2,23 @@ from __future__ import annotations import pytest -from pydantic import SecretStr -from mountainash_data.core.settings.auth import TokenAuth -from mountainash_data.core.settings.motherduck import MotherDuckAuthSettings +from mountainash_data.core.settings.motherduck import MotherDuckBackendProfile @pytest.mark.unit -class TestMotherDuckAuthSettings: +class TestMotherDuckBackendProfile: def test_minimal(self): - s = MotherDuckAuthSettings( - DATABASE="mydb", auth=TokenAuth(token=SecretStr("t")) - ) + s = MotherDuckBackendProfile(DATABASE="mydb") assert s.DATABASE == "mydb" def test_no_database_ok(self): """Audit regression: previously validator rejected None, field was Optional.""" - s = MotherDuckAuthSettings(auth=TokenAuth(token=SecretStr("t"))) + s = MotherDuckBackendProfile() assert s.DATABASE is None - def test_to_driver_kwargs_unwraps_token(self): - s = MotherDuckAuthSettings( - DATABASE="mydb", auth=TokenAuth(token=SecretStr("tok")) - ) - kwargs = s.to_driver_kwargs() - assert kwargs["token"] == "tok" - assert isinstance(kwargs["token"], str) # not SecretStr + def test_to_url_parts_uses_md_scheme(self): + s = MotherDuckBackendProfile(DATABASE="mydb") + parts = s.to_url_parts() + assert parts.scheme == "md" + assert parts.database == "mydb" diff --git a/tests/test_unit/core/settings/backends/test_mssql.py b/tests/test_unit/core/settings/backends/test_mssql.py index a839911..670f585 100644 --- a/tests/test_unit/core/settings/backends/test_mssql.py +++ b/tests/test_unit/core/settings/backends/test_mssql.py @@ -2,62 +2,35 @@ from __future__ import annotations import pytest -from pydantic import SecretStr -from mountainash_data.core.settings.auth import ( - AzureADAuth, - PasswordAuth, - WindowsAuth, -) +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE from mountainash_data.core.settings.mssql import ( - MSSQLAuthSettings, + MSSQLBackendProfile, MSSQLEncryption, ) @pytest.mark.unit -class TestMSSQLAuthSettings: - def _minimal(self, auth, **extra): - return MSSQLAuthSettings(HOST="h", DATABASE="d", auth=auth, **extra) - - def test_password_auth(self): - s = self._minimal( - auth=PasswordAuth(username="u", password=SecretStr("p")) - ) - kwargs = s.to_driver_kwargs() - assert kwargs["user"] == "u" - assert kwargs["password"] == "p" - assert kwargs["host"] == "h" +class TestMSSQLBackendProfile: + def _minimal(self, **extra): + return MSSQLBackendProfile(HOST="h", DATABASE="d", **extra) - def test_windows_auth_sets_trusted_connection(self): - s = self._minimal(auth=WindowsAuth(username="u", domain="CORP")) - kwargs = s.to_driver_kwargs() - assert kwargs["trusted_connection"] == "yes" - assert kwargs["user"] == r"CORP\u" - - def test_azure_ad_managed_identity(self): - """Audit regression: AZURE_MANAGED_IDENTITY/MSI_ENDPOINT were - referenced but not declared — now live on AzureADAuth.""" - s = self._minimal( - auth=AzureADAuth( - managed_identity=True, - msi_endpoint="http://169.254.169.254/", - ) - ) - kwargs = s.to_driver_kwargs() - assert kwargs["authentication"] == "ActiveDirectoryMsi" - assert kwargs["msi_endpoint"] == "http://169.254.169.254/" - - def test_instance_name_appended_to_host(self): - """Audit regression: code referenced args['server'] (KeyError).""" - s = self._minimal( - auth=PasswordAuth(username="u", password=SecretStr("p")), - INSTANCE_NAME="SQLEXPRESS", - ) - kwargs = s.to_driver_kwargs() - assert kwargs["host"] == r"h\SQLEXPRESS" + def test_emit_plumbs_host_and_database(self): + s = self._minimal() + kwargs = s.emit(CONST_DB_PROVIDER_TYPE.MSSQL) + assert kwargs["host"] == "h" + assert kwargs["database"] == "d" def test_encryption_default(self): """Audit regression: ODBC Driver 18 default Encrypt=Yes requires explicit setting.""" - s = self._minimal(auth=PasswordAuth(username="u", password=SecretStr("p"))) + s = self._minimal() assert s.ENCRYPTION is MSSQLEncryption.MANDATORY + + def test_default_port(self): + s = self._minimal() + assert s.PORT == 1433 + + def test_instance_name_stored(self): + """Audit regression: code referenced args['server'] (KeyError).""" + s = self._minimal(INSTANCE_NAME="SQLEXPRESS") + assert s.INSTANCE_NAME == "SQLEXPRESS" diff --git a/tests/test_unit/core/settings/backends/test_mysql.py b/tests/test_unit/core/settings/backends/test_mysql.py index 82521b5..3ce9f5e 100644 --- a/tests/test_unit/core/settings/backends/test_mysql.py +++ b/tests/test_unit/core/settings/backends/test_mysql.py @@ -2,45 +2,37 @@ from __future__ import annotations import pytest -from pydantic import SecretStr -from mountainash_data.core.settings.auth import PasswordAuth -from mountainash_data.core.settings.mysql import MySQLAuthSettings, MySQLSSLMode +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE +from mountainash_data.core.settings.mysql import MySQLBackendProfile, MySQLSSLMode @pytest.mark.unit -class TestMySQLAuthSettings: +class TestMySQLBackendProfile: def _minimal(self, **extra): - return MySQLAuthSettings( - HOST="h", DATABASE="d", - auth=PasswordAuth(username="u", password=SecretStr("p")), - **extra, - ) + return MySQLBackendProfile(HOST="h", DATABASE="d", **extra) def test_provider_type_is_mysql(self): """Audit regression: previously returned BIGQUERY.""" from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE assert self._minimal().provider_type == CONST_DB_PROVIDER_TYPE.MYSQL - def test_ssl_dict_assembled_when_capath_only_no_ca(self): + def test_ssl_capath_stored(self): """Audit regression: SSL_CAPATH was gated on SSL_CA.""" + from pathlib import Path s = self._minimal(SSL_CAPATH="/etc/ssl/ca-dir") - kwargs = s.to_driver_kwargs() - assert kwargs["ssl"] == {"ssl-capath": "/etc/ssl/ca-dir"} + assert s.SSL_CAPATH == Path("/etc/ssl/ca-dir") - def test_ssl_not_emitted_when_ssl_mode_none(self): + def test_ssl_mode_none_default(self): """Audit regression: SSL branch fired when SSL_MODE was None.""" s = self._minimal() - kwargs = s.to_driver_kwargs() - assert "ssl_mode" not in kwargs - assert "ssl" not in kwargs + assert s.SSL_MODE is None - def test_ssl_mode_preferred(self): + def test_ssl_mode_preferred_stored(self): s = self._minimal(SSL_MODE=MySQLSSLMode.PREFERRED) - kwargs = s.to_driver_kwargs() - assert kwargs["ssl_mode"] == "PREFERRED" + assert s.SSL_MODE == MySQLSSLMode.PREFERRED def test_autocommit_false_honored(self): """Audit regression: `if self.AUTOCOMMIT:` dropped explicit False.""" s = self._minimal(AUTOCOMMIT=False) - assert s.to_driver_kwargs()["autocommit"] is False + assert s.emit(CONST_DB_PROVIDER_TYPE.MYSQL)["autocommit"] is False diff --git a/tests/test_unit/core/settings/backends/test_postgresql.py b/tests/test_unit/core/settings/backends/test_postgresql.py index c4e42dd..ef07eda 100644 --- a/tests/test_unit/core/settings/backends/test_postgresql.py +++ b/tests/test_unit/core/settings/backends/test_postgresql.py @@ -2,24 +2,19 @@ from __future__ import annotations import pytest -from pydantic import SecretStr, ValidationError +from pydantic import ValidationError -from mountainash_data.core.settings.auth import PasswordAuth from mountainash_data.core.settings.postgresql import ( PostgresRequireAuthMethods, PostgresSSLMode, - PostgreSQLAuthSettings, + PostgreSQLBackendProfile, ) @pytest.mark.unit -class TestPostgreSQLAuthSettings: +class TestPostgreSQLBackendProfile: def _minimal(self, **extra): - return PostgreSQLAuthSettings( - HOST="h", DATABASE="d", - auth=PasswordAuth(username="u", password=SecretStr("p")), - **extra, - ) + return PostgreSQLBackendProfile(HOST="h", DATABASE="d", **extra) def test_provider_type_is_postgresql(self): """Audit regression: previously returned BIGQUERY.""" @@ -46,11 +41,10 @@ def test_require_auth_is_list_of_enum(self): ) assert len(s.REQUIRE_AUTH) == 2 - def test_to_driver_kwargs_plumbs_ssl_and_keepalives(self): + def test_emit_plumbs_ssl_and_keepalives(self): """Audit regression: only SCHEMA was being plumbed.""" s = self._minimal(SSL_MODE=PostgresSSLMode.REQUIRE, KEEPALIVES_IDLE=30) - kwargs = s.to_driver_kwargs() + kwargs = s.emit() assert kwargs["sslmode"] == "require" assert kwargs["keepalives_idle"] == 30 - assert kwargs["user"] == "u" - assert kwargs["password"] == "p" # SecretStr unwrapped + assert kwargs["host"] == "h" diff --git a/tests/test_unit/core/settings/backends/test_pyiceberg_rest.py b/tests/test_unit/core/settings/backends/test_pyiceberg_rest.py index 75acc5b..a40b4e4 100644 --- a/tests/test_unit/core/settings/backends/test_pyiceberg_rest.py +++ b/tests/test_unit/core/settings/backends/test_pyiceberg_rest.py @@ -2,46 +2,36 @@ from __future__ import annotations import pytest -from pydantic import SecretStr -from mountainash_data.core.settings.auth import OAuth2Auth, TokenAuth -from mountainash_data.core.settings.pyiceberg_rest import PyIcebergRestAuthSettings +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE +from mountainash_data.core.settings.pyiceberg_rest import PyIcebergRestBackendProfile @pytest.mark.unit -class TestPyIcebergRestAuthSettings: - def _min(self, auth, **extra): - return PyIcebergRestAuthSettings( +class TestPyIcebergRestBackendProfile: + def _min(self, **extra): + return PyIcebergRestBackendProfile( CATALOG_NAME="cat", CATALOG_URI="https://catalog.example/v1", - auth=auth, **extra, + **extra, ) def test_warehouse_optional(self): """Audit regression: WAREHOUSE was over-required.""" - s = self._min(auth=TokenAuth(token=SecretStr("t"))) + s = self._min() assert s.WAREHOUSE is None - def test_token_auth(self): - s = self._min(auth=TokenAuth(token=SecretStr("tok"))) - kwargs = s.to_driver_kwargs() - assert kwargs["token"] == "tok" + def test_emit_plumbs_uri(self): + s = self._min() + kwargs = s.emit(CONST_DB_PROVIDER_TYPE.PYICEBERG_REST) assert kwargs["uri"] == "https://catalog.example/v1" + assert kwargs["name"] == "cat" - def test_oauth2_credential_form(self): + def test_s3_params_stored(self): + """Audit regression: s3.* family was absent from the spec.""" s = self._min( - auth=OAuth2Auth(client_id="cid", client_secret=SecretStr("sec")), - ) - kwargs = s.to_driver_kwargs() - assert kwargs["credential"] == "cid:sec" - - def test_s3_params_prefixed(self): - """Audit regression: s3.* family was absent.""" - s = self._min( - auth=TokenAuth(token=SecretStr("t")), S3_ENDPOINT="https://r2.example.com", S3_REGION="auto", ) - kwargs = s.to_driver_kwargs() - assert kwargs["s3.endpoint"] == "https://r2.example.com" - assert kwargs["s3.region"] == "auto" + assert s.S3_ENDPOINT == "https://r2.example.com" + assert s.S3_REGION == "auto" diff --git a/tests/test_unit/core/settings/backends/test_pyspark.py b/tests/test_unit/core/settings/backends/test_pyspark.py index 788104f..8e5ccdf 100644 --- a/tests/test_unit/core/settings/backends/test_pyspark.py +++ b/tests/test_unit/core/settings/backends/test_pyspark.py @@ -4,47 +4,45 @@ import pytest -from mountainash_data.core.settings.auth import NoAuth from mountainash_data.core.settings.pyspark import ( - PySparkAuthSettings, + PySparkBackendProfile, PySparkMode, ) @pytest.mark.unit -class TestPySparkAuthSettings: +class TestPySparkBackendProfile: def test_minimal(self): - s = PySparkAuthSettings(auth=NoAuth()) + s = PySparkBackendProfile() assert s.MODE is PySparkMode.BATCH def test_mode_streaming(self): - s = PySparkAuthSettings(MODE="streaming", auth=NoAuth()) + s = PySparkBackendProfile(MODE="streaming") assert s.MODE is PySparkMode.STREAMING def test_mode_invalid_rejected(self): from pydantic import ValidationError with pytest.raises(ValidationError): - PySparkAuthSettings(MODE="nonsense", auth=NoAuth()) + PySparkBackendProfile(MODE="nonsense") def test_partitions_accepts_int(self): """Audit regression: PARTITIONS: int = {} crashed at init.""" - s = PySparkAuthSettings(PARTITIONS=200, auth=NoAuth()) + s = PySparkBackendProfile(PARTITIONS=200) assert s.PARTITIONS == 200 def test_partitions_none_default(self): - s = PySparkAuthSettings(auth=NoAuth()) + s = PySparkBackendProfile() assert s.PARTITIONS is None - def test_to_driver_kwargs_emits_dotted_spark_keys(self): + def test_emit_emits_dotted_spark_keys(self): """Audit regression: previously emitted 'spark_app_name' not 'spark.app.name'.""" - s = PySparkAuthSettings( + s = PySparkBackendProfile( APPLICATION_NAME="myapp", SPARK_MASTER="local[2]", MODE="batch", - auth=NoAuth(), ) - kwargs = s.to_driver_kwargs() + kwargs = s.emit() assert kwargs["mode"] == "batch" # Adapter emits dotted Spark keys: assert kwargs["spark.app.name"] == "myapp" diff --git a/tests/test_unit/core/settings/backends/test_redshift.py b/tests/test_unit/core/settings/backends/test_redshift.py index 159700e..6437ff9 100644 --- a/tests/test_unit/core/settings/backends/test_redshift.py +++ b/tests/test_unit/core/settings/backends/test_redshift.py @@ -2,55 +2,47 @@ from __future__ import annotations import pytest -from pydantic import SecretStr, ValidationError +from pydantic import ValidationError -from mountainash_data.core.settings.auth import IAMAuth, PasswordAuth from mountainash_data.core.settings.redshift import ( - RedshiftAuthSettings, + RedshiftBackendProfile, RedshiftSSLMode, ) @pytest.mark.unit -class TestRedshiftAuthSettings: - def _password(self, **extra): - return RedshiftAuthSettings( +class TestRedshiftBackendProfile: + def _minimal(self, **extra): + return RedshiftBackendProfile( HOST="cluster.abc.us-east-1.redshift.amazonaws.com", DATABASE="dev", REGION="us-east-1", - auth=PasswordAuth(username="u", password=SecretStr("p")), **extra, ) def test_port_default_5439(self): - s = self._password() + s = self._minimal() assert s.PORT == 5439 def test_region_govcloud_accepted(self): """Audit regression: region regex rejected GovCloud.""" - s = RedshiftAuthSettings( + s = RedshiftBackendProfile( HOST="h", DATABASE="d", REGION="us-gov-west-1", - auth=PasswordAuth(username="u", password=SecretStr("p")), ) assert s.REGION == "us-gov-west-1" def test_role_arn_govcloud_accepted(self): """Audit regression: role-ARN regex rejected non-commercial partitions.""" - s = self._password(IAM_ROLE_ARN="arn:aws-us-gov:iam::123456789012:role/x") + s = self._minimal(IAM_ROLE_ARN="arn:aws-us-gov:iam::123456789012:role/x") assert s.IAM_ROLE_ARN.startswith("arn:aws-us-gov:") - def test_iam_auth(self): - s = RedshiftAuthSettings( - HOST="h", DATABASE="d", REGION="us-east-1", - auth=IAMAuth( - access_key_id="AKIA", secret_access_key=SecretStr("sk"), - ), - ) - kwargs = s.to_driver_kwargs() - assert kwargs["aws_access_key_id"] == "AKIA" - assert kwargs["aws_secret_access_key"] == "sk" + def test_emit_plumbs_host_and_port(self): + s = self._minimal() + kwargs = s.emit() + assert kwargs["host"] == "cluster.abc.us-east-1.redshift.amazonaws.com" + assert kwargs["port"] == 5439 def test_ssl_mode_enum(self): """Audit regression: SSL was bool, hardcoded verify-full.""" - s = self._password(SSL_MODE=RedshiftSSLMode.REQUIRE) - assert s.to_driver_kwargs()["sslmode"] == "require" + s = self._minimal(SSL_MODE=RedshiftSSLMode.REQUIRE) + assert s.emit()["sslmode"] == "require" diff --git a/tests/test_unit/core/settings/backends/test_risingwave.py b/tests/test_unit/core/settings/backends/test_risingwave.py index 21b4f74..e84bc3b 100644 --- a/tests/test_unit/core/settings/backends/test_risingwave.py +++ b/tests/test_unit/core/settings/backends/test_risingwave.py @@ -2,21 +2,15 @@ from __future__ import annotations import pytest -from pydantic import SecretStr from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE -from mountainash_data.core.settings.auth import NoAuth, PasswordAuth -from mountainash_data.core.settings.risingwave import RisingWaveAuthSettings +from mountainash_data.core.settings.risingwave import RisingWaveBackendProfile @pytest.mark.unit -class TestRisingWaveAuthSettings: +class TestRisingWaveBackendProfile: def _minimal(self, **extra): - return RisingWaveAuthSettings( - HOST="rw.example.com", - auth=PasswordAuth(username="root", password=SecretStr("s3cret")), - **extra, - ) + return RisingWaveBackendProfile(HOST="rw.example.com", **extra) def test_provider_type(self): assert self._minimal().provider_type == CONST_DB_PROVIDER_TYPE.RISINGWAVE @@ -24,17 +18,15 @@ def test_provider_type(self): def test_default_port(self): assert self._minimal().PORT == 5432 - def test_to_driver_kwargs(self): - kwargs = self._minimal(DATABASE="dev", SCHEMA="public").to_driver_kwargs() + def test_emit(self): + kwargs = self._minimal(DATABASE="dev", SCHEMA="public").emit() assert kwargs["host"] == "rw.example.com" assert kwargs["port"] == 5432 assert kwargs["database"] == "dev" assert kwargs["schema"] == "public" - assert kwargs["user"] == "root" - assert kwargs["password"] == "s3cret" - def test_no_auth(self): - s = RisingWaveAuthSettings(HOST="rw.local", auth=NoAuth()) + def test_minimal_construction(self): + s = RisingWaveBackendProfile(HOST="rw.local") assert s.HOST == "rw.local" def test_ibis_dialect(self): diff --git a/tests/test_unit/core/settings/backends/test_singlestoredb.py b/tests/test_unit/core/settings/backends/test_singlestoredb.py index 6780383..7933338 100644 --- a/tests/test_unit/core/settings/backends/test_singlestoredb.py +++ b/tests/test_unit/core/settings/backends/test_singlestoredb.py @@ -2,22 +2,20 @@ from __future__ import annotations import pytest -from pydantic import SecretStr, ValidationError +from pydantic import ValidationError from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE -from mountainash_data.core.settings.auth import NoAuth, PasswordAuth from mountainash_data.core.settings.singlestoredb import ( - SingleStoreDBAuthSettings, + SingleStoreDBBackendProfile, SingleStoreDriver, ) @pytest.mark.unit -class TestSingleStoreDBAuthSettings: +class TestSingleStoreDBBackendProfile: def _minimal(self, **extra): - return SingleStoreDBAuthSettings( + return SingleStoreDBBackendProfile( HOST="svc-123.svc.singlestore.com", - auth=PasswordAuth(username="admin", password=SecretStr("s3cret")), **extra, ) @@ -43,7 +41,7 @@ def test_driver_mysql(self): def test_driver_https(self): s = self._minimal(DRIVER=SingleStoreDriver.HTTPS) - kwargs = s.to_driver_kwargs() + kwargs = s.emit() assert kwargs["driver"] == "https" def test_autocommit_default_true(self): @@ -54,20 +52,16 @@ def test_local_infile_default_true(self): s = self._minimal() assert s.LOCAL_INFILE is True - def test_no_auth(self): - s = SingleStoreDBAuthSettings( - HOST="svc-123.svc.singlestore.com", auth=NoAuth(), - ) + def test_minimal_construction(self): + s = SingleStoreDBBackendProfile(HOST="svc-123.svc.singlestore.com") assert s.HOST == "svc-123.svc.singlestore.com" - def test_to_driver_kwargs_plumbs_core_fields(self): + def test_emit_plumbs_core_fields(self): s = self._minimal(PORT=3307, DATABASE="mydb") - kwargs = s.to_driver_kwargs() + kwargs = s.emit() assert kwargs["host"] == "svc-123.svc.singlestore.com" assert kwargs["port"] == 3307 assert kwargs["database"] == "mydb" - assert kwargs["user"] == "admin" - assert kwargs["password"] == "s3cret" def test_ibis_dialect(self): s = self._minimal() diff --git a/tests/test_unit/core/settings/backends/test_snowflake.py b/tests/test_unit/core/settings/backends/test_snowflake.py index 1659bcf..dbea811 100644 --- a/tests/test_unit/core/settings/backends/test_snowflake.py +++ b/tests/test_unit/core/settings/backends/test_snowflake.py @@ -3,62 +3,46 @@ from __future__ import annotations import pytest -from pydantic import SecretStr -from mountainash_data.core.settings.auth import ( - CertificateAuth, - OAuth2Auth, - PasswordAuth, - TokenAuth, -) +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE from mountainash_data.core.settings.snowflake import ( SnowflakeAuthenticator, - SnowflakeAuthSettings, + SnowflakeBackendProfile, ) @pytest.mark.unit -class TestSnowflakeAuthSettings: - def _minimal(self, auth, **extra): - return SnowflakeAuthSettings( - ACCOUNT="acc", WAREHOUSE="wh", auth=auth, **extra, - ) +class TestSnowflakeBackendProfile: + def _minimal(self, **extra): + return SnowflakeBackendProfile(ACCOUNT="acc", WAREHOUSE="wh", **extra) def test_authenticator_enum_has_no_whitespace(self): """Audit regression: enum values had trailing spaces.""" assert SnowflakeAuthenticator.SNOWFLAKE.value == "snowflake" assert SnowflakeAuthenticator.PASSWORD_MFA.value == "username_password_mfa" - def test_password_auth(self): - s = self._minimal( - auth=PasswordAuth(username="u", password=SecretStr("p")) - ) - kwargs = s.to_driver_kwargs() + def test_emit_plumbs_account_and_warehouse(self): + s = self._minimal() + kwargs = s.emit(CONST_DB_PROVIDER_TYPE.SNOWFLAKE) assert kwargs["account"] == "acc" assert kwargs["warehouse"] == "wh" - assert kwargs["user"] == "u" - assert kwargs["password"] == "p" def test_role_is_plumbed(self): """Audit regression: ROLE was declared but never emitted.""" - s = self._minimal( - auth=PasswordAuth(username="u", password=SecretStr("p")), - ROLE="analyst", - ) - assert s.to_driver_kwargs()["role"] == "analyst" - - def test_timezone_goes_to_session_parameters(self): - """Audit regression: TIMEZONE was top-level, should be in session_parameters.""" - s = self._minimal( - auth=PasswordAuth(username="u", password=SecretStr("p")), - TIMEZONE="UTC", - ) - kwargs = s.to_driver_kwargs() - assert kwargs["session_parameters"] == {"TIMEZONE": "UTC"} - - def test_certificate_auth(self): - s = self._minimal( - auth=CertificateAuth(private_key=SecretStr("KEYCONTENT")) - ) - kwargs = s.to_driver_kwargs() - assert kwargs["private_key"] == "KEYCONTENT" + s = self._minimal(ROLE="analyst") + assert s.emit(CONST_DB_PROVIDER_TYPE.SNOWFLAKE)["role"] == "analyst" + + def test_authenticator_is_emitted_as_str(self): + """Regression: AUTHENTICATOR (e.g. Okta/MFA) must reach the driver kwargs.""" + s = self._minimal(AUTHENTICATOR=SnowflakeAuthenticator.PASSWORD_MFA) + out = s.emit(CONST_DB_PROVIDER_TYPE.SNOWFLAKE) + assert out["authenticator"] == "username_password_mfa" + assert isinstance(out["authenticator"], str) + + def test_authenticator_absent_when_unset(self): + assert "authenticator" not in self._minimal().emit(CONST_DB_PROVIDER_TYPE.SNOWFLAKE) + + def test_timezone_stored(self): + """Audit regression: TIMEZONE was top-level.""" + s = self._minimal(TIMEZONE="UTC") + assert s.TIMEZONE == "UTC" diff --git a/tests/test_unit/core/settings/backends/test_sqlite.py b/tests/test_unit/core/settings/backends/test_sqlite.py index 67ec293..23e209d 100644 --- a/tests/test_unit/core/settings/backends/test_sqlite.py +++ b/tests/test_unit/core/settings/backends/test_sqlite.py @@ -4,30 +4,28 @@ import pytest -from mountainash_data.core.settings.auth import NoAuth -from mountainash_data.core.settings.sqlite import SQLiteAuthSettings +from mountainash_data.core.settings.sqlite import SQLiteBackendProfile @pytest.mark.unit -class TestSQLiteAuthSettings: +class TestSQLiteBackendProfile: def test_minimal_construction(self): - s = SQLiteAuthSettings(auth=NoAuth()) + s = SQLiteBackendProfile() assert s.DATABASE is None assert s.provider_type # non-empty def test_database_memory(self): - s = SQLiteAuthSettings(DATABASE=":memory:", auth=NoAuth()) + s = SQLiteBackendProfile(DATABASE=":memory:") assert s.DATABASE == ":memory:" - def test_to_driver_kwargs_memory(self): - s = SQLiteAuthSettings(DATABASE=":memory:", auth=NoAuth()) - assert s.to_driver_kwargs() == {"database": ":memory:"} + def test_emit_memory(self): + s = SQLiteBackendProfile(DATABASE=":memory:") + assert s.emit() == {"database": ":memory:"} - def test_to_driver_kwargs_none_database_dropped(self): - s = SQLiteAuthSettings(auth=NoAuth()) - assert s.to_driver_kwargs() == {} + def test_emit_none_database_dropped(self): + s = SQLiteBackendProfile() + assert s.emit() == {} def test_type_map_optional(self): - s = SQLiteAuthSettings(DATABASE=":memory:", TYPE_MAP={"SMALLINT": "int32"}, - auth=NoAuth()) - assert s.to_driver_kwargs()["type_map"] == {"SMALLINT": "int32"} + s = SQLiteBackendProfile(DATABASE=":memory:", TYPE_MAP={"SMALLINT": "int32"}) + assert s.emit()["type_map"] == {"SMALLINT": "int32"} diff --git a/tests/test_unit/core/settings/backends/test_trino.py b/tests/test_unit/core/settings/backends/test_trino.py index 19e8940..debe689 100644 --- a/tests/test_unit/core/settings/backends/test_trino.py +++ b/tests/test_unit/core/settings/backends/test_trino.py @@ -1,56 +1,35 @@ -"""Trino backend settings tests. - -Tests migration with auth-wrapper adapter. -""" +"""Trino backend settings tests.""" from __future__ import annotations import pytest -from pydantic import SecretStr -from mountainash_data.core.settings.auth import ( - JWTAuth, - KerberosAuth, - NoAuth, - PasswordAuth, -) -from mountainash_data.core.settings.trino import TrinoAuthSettings +from mountainash_data.core.settings.trino import TrinoBackendProfile @pytest.mark.unit -class TestTrinoAuthSettings: - def _minimal(self, auth, **extra): - return TrinoAuthSettings(HOST="h", CATALOG="c", auth=auth, **extra) +class TestTrinoBackendProfile: + def _minimal(self, **extra): + return TrinoBackendProfile(HOST="h", CATALOG="c", **extra) def test_port_default_8080(self): - s = self._minimal(auth=NoAuth()) + s = self._minimal() assert s.PORT == 8080 - def test_password_wraps_basic_auth(self): - """Audit regression: previously emitted bare `password=` kwarg. - - The driver has NO `password` kwarg — it must be wrapped. - """ - pytest.importorskip("trino") - from trino.auth import BasicAuthentication - - s = self._minimal( - auth=PasswordAuth(username="alice", password=SecretStr("pw")) - ) - kwargs = s.to_driver_kwargs() - assert kwargs["user"] == "alice" - assert isinstance(kwargs["auth"], BasicAuthentication) - assert "password" not in kwargs # must NOT be bare - - def test_jwt_auth_wraps(self): - pytest.importorskip("trino") - from trino.auth import JWTAuthentication - - s = self._minimal(auth=JWTAuth(token=SecretStr("tok"))) - kwargs = s.to_driver_kwargs() - assert isinstance(kwargs["auth"], JWTAuthentication) - - def test_noauth_no_auth_key(self): - s = self._minimal(auth=NoAuth()) - kwargs = s.to_driver_kwargs() + def test_emit_core_fields(self): + s = self._minimal() + kwargs = s.emit() + assert kwargs["host"] == "h" + assert kwargs["catalog"] == "c" + assert kwargs["port"] == 8080 + + def test_http_scheme_default_https(self): + s = self._minimal() + assert s.HTTP_SCHEME == "https" + + def test_noauth_emits_no_auth_key(self): + """Profile emit() must not include an 'auth' key — auth is orthogonal.""" + s = self._minimal() + kwargs = s.emit() assert "auth" not in kwargs + assert "password" not in kwargs diff --git a/tests/test_unit/core/settings/test_config_shaping.py b/tests/test_unit/core/settings/test_config_shaping.py new file mode 100644 index 0000000..28a4a64 --- /dev/null +++ b/tests/test_unit/core/settings/test_config_shaping.py @@ -0,0 +1,35 @@ +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings import ( + MySQLBackendProfile, MSSQLBackendProfile, SnowflakeBackendProfile, + PyIcebergRestBackendProfile, +) + + +def test_mysql_ssl_compose_full_dict(): + out = MySQLBackendProfile(HOST="h", PORT=3306, SSL_CA="/ca.pem", SSL_CIPHER="HIGH").emit(P.MYSQL) + # full equality: nested ssl ADDED, no flat ssl_* leaked, config unchanged + assert out == { + "host": "h", "port": 3306, "charset": "utf8mb4", + "collation": "utf8mb4_unicode_ci", "autocommit": True, + "ssl": {"ssl-ca": "/ca.pem", "ssl-cipher": "HIGH"}, + } + + +def test_mssql_host_fold_full_dict(): + out = MSSQLBackendProfile(HOST="srv", PORT=1433, INSTANCE_NAME="INST").emit(P.MSSQL) + assert out["host"] == "srv\\INST" and "instance_name" not in out + + +def test_snowflake_session_parameters_added_only(): + out = SnowflakeBackendProfile(ACCOUNT="acct", QUERY_TAG="etl", TIMEZONE="UTC").emit(P.SNOWFLAKE) + assert out["session_parameters"] == {"QUERY_TAG": "etl", "TIMEZONE": "UTC"} + assert "query_tag" not in out and "timezone" not in out + + +def test_pyiceberg_headers_expand_s3_flat(): + out = PyIcebergRestBackendProfile( + CATALOG_NAME="c", CATALOG_URI="http://x", S3_REGION="us-east-1", + HEADERS={"X-A": "1", "X-B": "2"}, + ).emit(P.PYICEBERG_REST) + assert out["name"] == "c" and out["uri"] == "http://x" and out["s3.region"] == "us-east-1" + assert out["header.X-A"] == "1" and out["header.X-B"] == "2" and "headers" not in out diff --git a/tests/test_unit/core/settings/test_descriptor.py b/tests/test_unit/core/settings/test_descriptor.py index 3469d7a..637be8d 100644 --- a/tests/test_unit/core/settings/test_descriptor.py +++ b/tests/test_unit/core/settings/test_descriptor.py @@ -2,7 +2,7 @@ import pytest -from mountainash_data.core.settings.auth import NoAuth +from mountainash_auth_client import NoAuthProfile from mountainash_data.core.settings.descriptor import ( BackendSpec, ParameterSpec, @@ -14,7 +14,7 @@ class TestBackendSpec: def test_default_port_field(self): d = BackendSpec( name="x", provider_type="x", - parameters=[], auth_modes=[NoAuth], + parameters=[], supported_auth=(NoAuthProfile,), default_port=5432, ) assert d.default_port == 5432 @@ -22,7 +22,7 @@ def test_default_port_field(self): def test_connection_string_scheme_field(self): d = BackendSpec( name="x", provider_type="x", - parameters=[], auth_modes=[NoAuth], + parameters=[], supported_auth=(NoAuthProfile,), connection_string_scheme="postgresql://", ) assert d.connection_string_scheme == "postgresql://" @@ -30,7 +30,7 @@ def test_connection_string_scheme_field(self): def test_rides_on_field(self): d = BackendSpec( name="motherduck", provider_type="motherduck", - parameters=[], auth_modes=[NoAuth], + parameters=[], supported_auth=(NoAuthProfile,), rides_on="duckdb", ) assert d.rides_on == "duckdb" @@ -38,7 +38,7 @@ def test_rides_on_field(self): def test_frozen(self): d = BackendSpec( name="x", provider_type="x", - parameters=[], auth_modes=[NoAuth], + parameters=[], supported_auth=(NoAuthProfile,), ) with pytest.raises(Exception): d.name = "y" # type: ignore diff --git a/tests/test_unit/core/settings/test_profile.py b/tests/test_unit/core/settings/test_profile.py index 3a7aa24..75b1eb5 100644 --- a/tests/test_unit/core/settings/test_profile.py +++ b/tests/test_unit/core/settings/test_profile.py @@ -1,21 +1,19 @@ -"""Tests for ConnectionProfile — database-flavored Profile. +"""Tests for BackendProfile — database-flavored Profile. Profile mechanism tests live in mountainash-settings. Here we only -exercise the database-specific methods: to_driver_kwargs() and -to_connection_string(). +exercise the database-specific methods: emit() and to_url_parts(). """ from __future__ import annotations import pytest -from pydantic import SecretStr -from mountainash_data.core.settings.auth import NoAuth, PasswordAuth +from mountainash_auth_client import NoAuthProfile, PasswordAuthProfile from mountainash_data.core.settings.descriptor import ( BackendSpec, ParameterSpec, ) -from mountainash_data.core.settings.profile import ConnectionProfile +from mountainash_data.core.settings.profile import BackendProfile DUMMY_SPEC = BackendSpec( @@ -23,75 +21,59 @@ provider_type="dummy", default_port=9999, connection_string_scheme="dummy://", + supported_auth=(NoAuthProfile, PasswordAuthProfile), 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="DATABASE", type=str, tier="core", default=None, driver_key="database"), ], - auth_modes=[NoAuth, PasswordAuth], ) -class DummyProfile(ConnectionProfile): +class DummyProfile(BackendProfile): __spec__ = DUMMY_SPEC @pytest.mark.unit -class TestConnectionProfile: - def test_to_driver_kwargs_default(self): - p = DummyProfile(HOST="h", PORT=1234, DATABASE="db", auth=NoAuth()) - kwargs = p.to_driver_kwargs() +class TestBackendProfile: + def test_emit_default(self): + p = DummyProfile(HOST="h", PORT=1234, DATABASE="db") + kwargs = p.emit() assert kwargs["host"] == "h" assert kwargs["port"] == 1234 assert kwargs["database"] == "db" - def test_to_driver_kwargs_password_unwrapped(self): - p = DummyProfile( - HOST="h", DATABASE="db", - auth=PasswordAuth(username="u", password=SecretStr("p")), - ) - kwargs = p.to_driver_kwargs() - assert kwargs["user"] == "u" - assert kwargs["password"] == "p" - - def test_to_driver_kwargs_adapter_owns_pipeline(self): + def test_emit_adapter_owns_pipeline(self): def _adapter(profile): return {"only": "thing"} - class Adapted(ConnectionProfile): + class Adapted(BackendProfile): __spec__ = DUMMY_SPEC __adapter__ = staticmethod(_adapter) - p = Adapted(HOST="h", auth=NoAuth()) - assert p.to_driver_kwargs() == {"only": "thing"} + p = Adapted(HOST="h") + assert p.emit() == {"only": "thing"} - 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" - - 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 + def test_to_url_parts_returns_skeleton(self): + p = DummyProfile(HOST="h", PORT=9999, DATABASE="db") + parts = p.to_url_parts() + assert parts.scheme == "dummy" + assert parts.host == "h" + assert parts.port == 9999 + assert parts.database == "db" - def test_to_connection_string_no_scheme_raises(self): + def test_to_url_parts_no_scheme_raises(self): spec = BackendSpec( - name="x", provider_type="x", parameters=[], auth_modes=[NoAuth], + name="x", provider_type="x", + supported_auth=(NoAuthProfile,), + parameters=[], connection_string_scheme=None, ) - class P(ConnectionProfile): + class P(BackendProfile): __spec__ = spec - p = P(auth=NoAuth()) + p = P() with pytest.raises(NotImplementedError): - p.to_connection_string() + p.to_url_parts() diff --git a/tests/test_unit/core/settings/test_registry.py b/tests/test_unit/core/settings/test_registry.py index 2c4d3cf..a55ee52 100644 --- a/tests/test_unit/core/settings/test_registry.py +++ b/tests/test_unit/core/settings/test_registry.py @@ -28,8 +28,8 @@ def test_get_descriptor_returns_correct_type(self): 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 + from mountainash_data.core.settings.sqlite import SQLiteBackendProfile + assert get_settings_class("sqlite") is SQLiteBackendProfile def test_legacy_REGISTRY_alias_still_works(self): import mountainash_data.core.settings # noqa: F401 diff --git a/tests/test_unit/core/settings/test_settings_flip.py b/tests/test_unit/core/settings/test_settings_flip.py new file mode 100644 index 0000000..e1ba14e --- /dev/null +++ b/tests/test_unit/core/settings/test_settings_flip.py @@ -0,0 +1,32 @@ +import pytest +from mountainash_auth_client import PasswordAuthProfile, NoAuthProfile +from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE as P +from mountainash_data.core.settings import ( + BackendProfile, PostgreSQLBackendProfile, MotherDuckBackendProfile, +) +from mountainash_data.core.settings.descriptor import BackendSpec + + +def test_supported_auth_present(): + assert PostgreSQLBackendProfile.__spec__.supported_auth == (PasswordAuthProfile, NoAuthProfile) + + +def test_flat_emit_is_config_only(): + out = PostgreSQLBackendProfile(HOST="db", PORT=5432, DATABASE="app").emit(P.POSTGRESQL) + assert out["host"] == "db" and out["port"] == 5432 and out["database"] == "app" + assert "user" not in out and "password" not in out + + +def test_to_url_parts_standard(): + parts = PostgreSQLBackendProfile(HOST="db", PORT=5432, DATABASE="app").to_url_parts() + assert (parts.scheme, parts.host, parts.port, parts.database) == ("postgresql", "db", 5432, "app") + + +def test_motherduck_url_parts_authority_less(): + parts = MotherDuckBackendProfile(DATABASE="mydb").to_url_parts() + assert parts.scheme == "md" and parts.host is None and parts.database == "mydb" + + +def test_empty_supported_auth_invariant(): + with pytest.raises(ValueError, match="supported_auth"): + BackendSpec(name="x", provider_type=P.SQLITE, parameters=[], supported_auth=()) diff --git a/tests/test_unit/core/settings/test_supported_auth_consistency.py b/tests/test_unit/core/settings/test_supported_auth_consistency.py new file mode 100644 index 0000000..1cb494d --- /dev/null +++ b/tests/test_unit/core/settings/test_supported_auth_consistency.py @@ -0,0 +1,18 @@ +"""Structural invariant: every non-NoAuth supported_auth entry has an auth adapter.""" + +import pytest +from mountainash_auth_client import NoAuthProfile +from mountainash_data.core.settings.adapters.registry import auth_adapter +from mountainash_data.core.factories.connection_factory import _iter_specs + + +@pytest.mark.unit +def test_every_supported_pair_has_an_adapter(): + """Every non-NoAuth supported_auth entry in a BackendSpec must have a registered adapter.""" + for spec in _iter_specs(): + for auth_cls in spec.supported_auth: + if auth_cls is NoAuthProfile: + continue + assert auth_adapter(spec.provider_type, auth_cls) is not None, ( + f"{spec.name}: supported {auth_cls.__name__} has no adapter" + ) diff --git a/tests/test_unit/databases/settings/test_settings_parametrized.py b/tests/test_unit/databases/settings/test_settings_parametrized.py index cb9c20a..45ccdb2 100644 --- a/tests/test_unit/databases/settings/test_settings_parametrized.py +++ b/tests/test_unit/databases/settings/test_settings_parametrized.py @@ -1,14 +1,14 @@ """Parametrized tests for database settings across all backends.""" import pytest +from mountainash_auth_client import NoAuthProfile from mountainash_data.core.settings import ( - ConnectionProfile, - SQLiteAuthSettings, - DuckDBAuthSettings, - PostgreSQLAuthSettings, - BigQueryAuthSettings, - SnowflakeAuthSettings, - NoAuth, + BackendProfile, + SQLiteBackendProfile, + DuckDBBackendProfile, + PostgreSQLBackendProfile, + BigQueryBackendProfile, + SnowflakeBackendProfile, ) from mountainash_data.core.constants import CONST_DB_PROVIDER_TYPE from mountainash_settings import SettingsParameters @@ -16,9 +16,11 @@ @pytest.mark.unit @pytest.mark.parametrize("settings_class,expected_provider", [ - (SQLiteAuthSettings, CONST_DB_PROVIDER_TYPE.SQLITE), - (DuckDBAuthSettings, CONST_DB_PROVIDER_TYPE.DUCKDB), - (PostgreSQLAuthSettings, CONST_DB_PROVIDER_TYPE.POSTGRESQL), + (SQLiteBackendProfile, CONST_DB_PROVIDER_TYPE.SQLITE), + (DuckDBBackendProfile, CONST_DB_PROVIDER_TYPE.DUCKDB), + (PostgreSQLBackendProfile, CONST_DB_PROVIDER_TYPE.POSTGRESQL), + (BigQueryBackendProfile, CONST_DB_PROVIDER_TYPE.BIGQUERY), + (SnowflakeBackendProfile, CONST_DB_PROVIDER_TYPE.SNOWFLAKE), ]) class TestSettingsInitialization: """Test settings initialization for all backend types.""" @@ -27,15 +29,15 @@ def test_settings_can_be_instantiated(self, settings_class, expected_provider): """Test that settings can be instantiated.""" settings_params = SettingsParameters.create( settings_class=settings_class, - kwargs={"DATABASE": ":memory:"} if settings_class in [SQLiteAuthSettings, DuckDBAuthSettings] else {} + kwargs={"DATABASE": ":memory:"} if settings_class in [SQLiteBackendProfile, DuckDBBackendProfile] else {} ) assert settings_params is not None assert settings_params.settings_class == settings_class def test_settings_inherits_from_base(self, settings_class, expected_provider): - """Test that all settings inherit from ConnectionProfile.""" - assert issubclass(settings_class, ConnectionProfile) + """Test that all settings inherit from BackendProfile.""" + assert issubclass(settings_class, BackendProfile) def test_settings_has_provider_type(self, settings_class, expected_provider): """Test that settings have correct provider type.""" @@ -45,11 +47,12 @@ def test_settings_has_provider_type(self, settings_class, expected_provider): @pytest.mark.unit @pytest.mark.parametrize("settings_class,required_fields", [ - (SQLiteAuthSettings, ["DATABASE"]), - (DuckDBAuthSettings, ["DATABASE"]), - # PostgreSQLAuthSettings uses ConnectionProfile with auth field instead of - # discrete USERNAME/PASSWORD fields at the top level; check the new fields. - (PostgreSQLAuthSettings, ["HOST", "PORT", "DATABASE"]), + (SQLiteBackendProfile, ["DATABASE"]), + (DuckDBBackendProfile, ["DATABASE"]), + # PostgreSQLBackendProfile: check the connection fields. + (PostgreSQLBackendProfile, ["HOST", "PORT", "DATABASE"]), + (BigQueryBackendProfile, ["PROJECT_ID"]), + (SnowflakeBackendProfile, ["ACCOUNT"]), ]) class TestSettingsRequiredFields: """Test required fields for different settings types.""" @@ -69,10 +72,10 @@ def test_settings_has_required_fields(self, settings_class, required_fields): @pytest.mark.unit @pytest.mark.parametrize("settings_class,test_config", [ - (SQLiteAuthSettings, {"DATABASE": ":memory:", "auth": NoAuth()}), - (SQLiteAuthSettings, {"DATABASE": "/tmp/test.db", "auth": NoAuth()}), - (DuckDBAuthSettings, {"DATABASE": ":memory:", "auth": NoAuth()}), - (DuckDBAuthSettings, {"DATABASE": "/tmp/test.duckdb", "auth": NoAuth()}), + (SQLiteBackendProfile, {"DATABASE": ":memory:"}), + (SQLiteBackendProfile, {"DATABASE": "/tmp/test.db"}), + (DuckDBBackendProfile, {"DATABASE": ":memory:"}), + (DuckDBBackendProfile, {"DATABASE": "/tmp/test.duckdb"}), ]) class TestSettingsConfiguration: """Test settings configuration with various values.""" @@ -95,18 +98,15 @@ def test_settings_stores_configuration(self, settings_class, test_config): settings = settings_params.get_settings() - # Only check non-auth fields (auth is a special object, not a simple value) for key, value in test_config.items(): - if key == "auth": - continue assert hasattr(settings, key) assert getattr(settings, key) == value @pytest.mark.unit @pytest.mark.parametrize("settings_class", [ - SQLiteAuthSettings, - DuckDBAuthSettings, + SQLiteBackendProfile, + DuckDBBackendProfile, ]) class TestSettingsParametersExtraction: """Test SettingsParameters extraction for all backends.""" @@ -115,31 +115,31 @@ def test_settings_can_extract_parameters(self, settings_class): """Test that settings can be extracted to parameters.""" settings_params = SettingsParameters.create( settings_class=settings_class, - kwargs={"DATABASE": ":memory:", "auth": NoAuth()} + kwargs={"DATABASE": ":memory:"} ) settings = settings_params.get_settings() assert settings is not None - assert isinstance(settings, ConnectionProfile) + assert isinstance(settings, BackendProfile) def test_extracted_settings_have_parameters_method(self, settings_class): """Test that extracted settings have extract_settings_parameters method.""" settings_params = SettingsParameters.create( settings_class=settings_class, - kwargs={"DATABASE": ":memory:", "auth": NoAuth()} + kwargs={"DATABASE": ":memory:"} ) settings = settings_params.get_settings() # Should have method to extract parameters back - assert hasattr(settings, 'extract_settings_parameters') or True + assert hasattr(settings, 'extract_settings_parameters') @pytest.mark.integration @pytest.mark.parametrize("settings_class,db_config", [ - (SQLiteAuthSettings, {"DATABASE": ":memory:", "auth": NoAuth()}), - (DuckDBAuthSettings, {"DATABASE": ":memory:", "auth": NoAuth()}), + (SQLiteBackendProfile, {"DATABASE": ":memory:"}), + (DuckDBBackendProfile, {"DATABASE": ":memory:"}), ]) class TestSettingsWithConnections: """Test that settings work with actual connections.""" @@ -162,7 +162,7 @@ def test_settings_work_with_ibis_backend_connect(self, settings_class, db_config kwargs=db_config ) backend = IbisBackend(settings_params) - backend.connect() + backend.connect(auth_profile=NoAuthProfile()) tables = backend.list_tables() assert isinstance(tables, list) backend.close() @@ -179,7 +179,7 @@ def test_sqlite_settings_require_database(self): # Try to create without DATABASE with pytest.raises((ValueError, KeyError, TypeError)): SettingsParameters.create( - settings_class=SQLiteAuthSettings, + settings_class=SQLiteBackendProfile, kwargs={} ) @@ -190,13 +190,13 @@ def test_duckdb_settings_require_database(self): # Try to create without DATABASE with pytest.raises((ValueError, KeyError, TypeError)): SettingsParameters.create( - settings_class=DuckDBAuthSettings, + settings_class=DuckDBBackendProfile, kwargs={} ) @pytest.mark.parametrize("settings_class,valid_config", [ - (SQLiteAuthSettings, {"DATABASE": ":memory:", "auth": NoAuth()}), - (DuckDBAuthSettings, {"DATABASE": ":memory:", "auth": NoAuth()}), + (SQLiteBackendProfile, {"DATABASE": ":memory:"}), + (DuckDBBackendProfile, {"DATABASE": ":memory:"}), ]) def test_valid_configuration_accepted(self, settings_class, valid_config): """Test that valid configurations are accepted."""