From 079394d59118f3f2b3d250d29142dff70af91396 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Fri, 3 Apr 2026 21:39:42 +1100 Subject: [PATCH 01/13] Bump version to 26.4.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/mountainash_settings/__version__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mountainash_settings/__version__.py b/src/mountainash_settings/__version__.py index 9f45e9d..e233240 100644 --- a/src/mountainash_settings/__version__.py +++ b/src/mountainash_settings/__version__.py @@ -1 +1 @@ -__version__="25.8.0" \ No newline at end of file +__version__="26.4.0" \ No newline at end of file From a02dcc0358c9160df655bd88af20e34f08cc38ec Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Tue, 14 Apr 2026 20:32:11 +1000 Subject: [PATCH 02/13] chore: disable mountainash-constants dependency and bump hatch versions Comment out mountainash-constants dependency loading in workflows and hatch.toml, upgrade hatchling to 1.29.0 and hatch to 1.16.5, and apply YAML formatting normalization. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/config/mountainash_dependencies.yml | 4 +- .../workflows/build-and-release-package.yml | 144 ++++++++---------- .../main-release-build-dependencies.yml | 42 +++-- .github/workflows/python-run-pytest.yml | 50 +++--- hatch.toml | 6 +- 5 files changed, 115 insertions(+), 131 deletions(-) diff --git a/.github/config/mountainash_dependencies.yml b/.github/config/mountainash_dependencies.yml index acfc208..3f2dbf5 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-constants + # org-name: mountainash-io # - name: mountainash-data # org-name: mountainash-io # - name: mountainash-settings diff --git a/.github/workflows/build-and-release-package.yml b/.github/workflows/build-and-release-package.yml index c4daabf..7ca254d 100644 --- a/.github/workflows/build-and-release-package.yml +++ b/.github/workflows/build-and-release-package.yml @@ -4,42 +4,40 @@ on: pull_request: types: [closed] branches: - - 'main' - - 'develop' - - 'release*' - - 'feature*' - - 'bugfix*' - - 'hotfix*' + - "main" + - "develop" + - "release*" + - "feature*" + - "bugfix*" + - "hotfix*" # Add manual workflow dispatch with fallback branch selection workflow_dispatch: inputs: - release_type: - description: 'Type of release to create' + description: "Type of release to create" required: true - default: 'production' - type: 'choice' + default: "production" + type: "choice" options: - production - rc - - beta + - beta source_branch: - description: 'Branch containing code to release' + description: "Branch containing code to release" required: true - default: 'main' - type: 'string' + default: "main" + type: "string" fallback_branch: - description: 'Fallback branch to use for dependencies' + description: "Fallback branch to use for dependencies" required: true - default: 'main' + default: "main" type: choice options: - - develop - - main - + - develop + - main jobs: build-and-release: @@ -50,12 +48,11 @@ jobs: matrix: os: [ubuntu-24.04] python-version: ["3.12"] - + env: - BUILD_ENV: 'build_github' + BUILD_ENV: "build_github" steps: - # ====================================================== # INITIALIZE @@ -70,14 +67,13 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Checkout Repository (PR) if: github.event_name == 'pull_request' uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 - + - name: Checkout Repository (Manual) if: github.event_name == 'workflow_dispatch' uses: actions/checkout@v4 @@ -85,7 +81,6 @@ jobs: ref: ${{ github.event.inputs.source_branch }} fetch-depth: 0 - - name: Set Branch Vars run: | if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then @@ -102,45 +97,40 @@ jobs: echo "MANUAL_RELEASE_TYPE=" >> $GITHUB_ENV fi - - # ====================================================== # DEPENDENCIES - name: Python Dependencies run: | - pip install hatchling==1.25.0 - pip install hatch==1.14.2 + pip install hatchling==1.29.0 + pip install hatch==1.16.5 # Checkout Mountain Ash Dependencies - - name: Load Dependencies - id: deps - uses: ./.github/actions/load-dependencies - with: - config-path: .github/config/mountainash_dependencies.yml - - - - - name: Checkout Dependencies - uses: ./.github/actions/checkout-dependencies - with: - dependencies: ${{ steps.deps.outputs.dependencies }} - target-branch: ${{ env.TARGET_BRANCH }} - default-branch: ${{ env.FALLBACK_BRANCH }} - token: ${{ secrets.CLONE_PRIVATE_REPOS_TOKEN }} - org-name: ${{ env.ORGNAME }} + # - name: Load Dependencies + # id: deps + # uses: ./.github/actions/load-dependencies + # with: + # config-path: .github/config/mountainash_dependencies.yml + + # - name: Checkout Dependencies + # uses: ./.github/actions/checkout-dependencies + # with: + # dependencies: ${{ steps.deps.outputs.dependencies }} + # target-branch: ${{ env.TARGET_BRANCH }} + # default-branch: ${{ env.FALLBACK_BRANCH }} + # token: ${{ secrets.CLONE_PRIVATE_REPOS_TOKEN }} + # org-name: ${{ env.ORGNAME }} # ====================================================== # CONFIGURE RELEASE - - name: Get Base Version id: base_version run: | # BASE_VERSION=$(python -c "import sys; sys.path.append('src'); from ${{env.PACKAGE_SRCDIR}}.__version__ import __version__; print(__version__)") BASE_VERSION=$(hatch version) echo "BASE_VERSION=${BASE_VERSION}" >> $GITHUB_ENV - + # Validate semantic version format if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Error: Base version must be in semantic version format (X.Y.Z)" @@ -156,7 +146,7 @@ jobs: IS_PRERELEASE="true" RELEASE_TITLE="" RELEASE_DESCRIPTION="" - + # Function to get latest version number get_latest_version() { local prefix="$1" @@ -165,12 +155,12 @@ jobs: "https://api.github.com/repos/${{ github.repository }}/releases" | \ jq -r --arg prefix "$prefix" --arg suffix "$suffix" \ "map(select(.tag_name | startswith(\$prefix) and contains(\$suffix))) | .[0].tag_name" || echo "" - } - + } + # Check for manual workflow run if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "Using manual release type: ${{ env.MANUAL_RELEASE_TYPE }}" - + case "${{ env.MANUAL_RELEASE_TYPE }}" in "production") RELEASE_TYPE="production" @@ -216,7 +206,7 @@ jobs: exit 1 fi ;; - + "develop") RELEASE_TYPE="rc" # Get latest RC number for this version @@ -226,7 +216,7 @@ jobs: RELEASE_TITLE="Release Candidate" RELEASE_DESCRIPTION="Release candidate for testing and validation" ;; - + *) if [[ "{{ env.SOURCE_BRANCH }}" == feature/* || "$SOURCE_BRANCH" == bugfix/* ]]; then RELEASE_TYPE="beta" @@ -242,10 +232,10 @@ jobs: ;; esac fi - + # Set full version FULL_VERSION="${BASE_VERSION}${VERSION_SUFFIX:+$VERSION_SUFFIX}" - + # Output all variables { echo "RELEASE_TYPE=${RELEASE_TYPE}" @@ -255,7 +245,7 @@ jobs: echo "RELEASE_TITLE=${RELEASE_TITLE}" echo "RELEASE_DESCRIPTION=${RELEASE_DESCRIPTION}" } >> $GITHUB_OUTPUT - + echo "VERSION=${FULL_VERSION}" >> $GITHUB_ENV - name: Validate Release @@ -265,7 +255,7 @@ jobs: echo "Error: Tag v${{ env.VERSION }} already exists" exit 1 fi - + # Check if release already exists RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ "https://api.github.com/repos/${{ github.repository }}/releases/tags/v${{ env.VERSION }}" \ @@ -304,7 +294,6 @@ jobs: echo "Checking version from Hatch:" hatch version - - name: Build Package id: build run: | @@ -347,19 +336,18 @@ jobs: prerelease: ${{ steps.release_config.outputs.IS_PRERELEASE }} body: | ${{ steps.release_config.outputs.RELEASE_DESCRIPTION }} - + ## Release Details - Type: ${{ steps.release_config.outputs.RELEASE_TYPE }} - Source Branch: ${{ github.head_ref }} - Target Branch: ${{ github.base_ref }} - Version: ${{ env.VERSION }} - + ## Package Information - Package: ${{ env.PACKAGE_NAME }} - Base Version: ${{ env.BASE_VERSION }} ${{ steps.release_config.outputs.VERSION_SUFFIX && format('- Version Suffix: {0}', steps.release_config.outputs.VERSION_SUFFIX) || '' }} - - name: Upload Package uses: actions/upload-release-asset@v1 env: @@ -395,35 +383,35 @@ jobs: # Configure git git config --global user.name "GitHub Actions" git config --global user.email "actions@github.com" - + # Clone the wheels repository git clone https://x-access-token:${{ secrets.CLONE_PRIVATE_REPOS_TOKEN }}@github.com/${{ env.ORGNAME }}/mountainash-wheels.git wheels-repo - + # Go to the wheels repository cd wheels-repo - + # Generate a unique branch name using a timestamp TIMESTAMP=$(date +%Y%m%d%H%M%S) BRANCH_NAME="release/${{ env.PACKAGE_NAME }}-${{ env.VERSION }}-${TIMESTAMP}" - + # Create a new branch for this release git checkout -b $BRANCH_NAME - + # Create package directory if it doesn't exist mkdir -p ${{ env.PACKAGE_NAME }} - + # Copy the newly built wheel to the repository cp ../${{ steps.build.outputs.WHEEL_FILE }} ${{ env.PACKAGE_NAME }}/ - + # Add the new wheel file git add . - + # Commit the changes git commit -m "Add ${{ steps.build.outputs.WHEEL_FILENAME }} to wheels repository" - + # Push the branch to the repository git push -u origin $BRANCH_NAME - + # Export the branch name for later steps echo "WHEELS_BRANCH=${BRANCH_NAME}" >> $GITHUB_ENV @@ -431,10 +419,10 @@ jobs: run: | # Create a simpler PR body PR_BODY="This PR adds the following wheel file to the wheels repository:\n- ${{ steps.build.outputs.WHEEL_FILENAME }}\n\nThis was automatically generated from the release workflow of ${{ github.repository }}." - + # Properly escape the PR body for JSON PR_BODY_ESCAPED=$(echo "$PR_BODY" | jq -Rs .) - + # Create the PR PR_RESPONSE=$(curl -X POST \ -H "Authorization: token ${{ secrets.CLONE_PRIVATE_REPOS_TOKEN }}" \ @@ -446,13 +434,13 @@ jobs: \"head\": \"${WHEELS_BRANCH}\", \"base\": \"main\" }") - + echo "API Response: $PR_RESPONSE" - + # Extract PR URL and number PR_URL=$(echo "$PR_RESPONSE" | jq -r '.html_url') PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number') - + # Add labels to the PR if [ "$PR_NUMBER" != "null" ]; then curl -X POST \ @@ -462,11 +450,11 @@ jobs: -d '{ "labels": ["automated", "wheel"] }' - + echo "PR_URL=${PR_URL}" >> $GITHUB_ENV echo "::notice::Pull Request created: ${PR_URL}" else echo "::error::Failed to create Pull Request" echo "$PR_RESPONSE" exit 1 - fi \ No newline at end of file + fi diff --git a/.github/workflows/main-release-build-dependencies.yml b/.github/workflows/main-release-build-dependencies.yml index 3e48384..65d05b4 100644 --- a/.github/workflows/main-release-build-dependencies.yml +++ b/.github/workflows/main-release-build-dependencies.yml @@ -1,10 +1,10 @@ # Pre-merge validation workflow -name: Validate Main Release PR - Build Dependencies +name: Validate Main Release PR - Build Dependencies on: pull_request: branches: - - 'main' + - "main" jobs: main-release-build-dependencies: @@ -14,12 +14,11 @@ jobs: matrix: os: [ubuntu-24.04] python-version: ["3.12"] - + env: - BUILD_ENV: 'build_github' + BUILD_ENV: "build_github" steps: - # ====================================================== # INITIALIZE @@ -32,7 +31,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.merge_commit_sha }} - fetch-depth: 0 # Get the target branch name (branch PR was merged into) + fetch-depth: 0 # Get the target branch name (branch PR was merged into) - name: Set Branch Vars run: | @@ -44,24 +43,24 @@ jobs: - name: Python Dependencies run: | - pip install hatchling==1.25.0 - pip install hatch==1.12.0 + pip install hatchling==1.29.0 + pip install hatch==1.16.5 # Checkout Mountain Ash Dependencies - - name: Load Dependencies - id: deps - uses: ./.github/actions/load-dependencies - with: - config-path: .github/config/mountainash_dependencies.yml + # - name: Load Dependencies + # id: deps + # uses: ./.github/actions/load-dependencies + # with: + # config-path: .github/config/mountainash_dependencies.yml - - name: Checkout Dependencies - uses: ./.github/actions/checkout-dependencies - with: - dependencies: ${{ steps.deps.outputs.dependencies }} - target-branch: ${{ env.TARGET_BRANCH }} - default-branch: main - token: ${{ secrets.CLONE_PRIVATE_REPOS_TOKEN }} - org-name: ${{ env.ORGNAME }} + # - name: Checkout Dependencies + # uses: ./.github/actions/checkout-dependencies + # with: + # dependencies: ${{ steps.deps.outputs.dependencies }} + # target-branch: ${{ env.TARGET_BRANCH }} + # default-branch: main + # token: ${{ secrets.CLONE_PRIVATE_REPOS_TOKEN }} + # org-name: ${{ env.ORGNAME }} # ====================================================== # BUILD ARTIFACTS @@ -69,4 +68,3 @@ jobs: - name: Setup Build Environment run: | hatch env create ${{ env.BUILD_ENV }} - diff --git a/.github/workflows/python-run-pytest.yml b/.github/workflows/python-run-pytest.yml index 7077924..632cf7b 100644 --- a/.github/workflows/python-run-pytest.yml +++ b/.github/workflows/python-run-pytest.yml @@ -1,29 +1,28 @@ name: Pytest -on: +on: pull_request_target: paths: - - 'src/mountainash_settings/**' + - "src/mountainash_settings/**" workflow_dispatch: inputs: fallback_branch: - description: 'Fallback branch to use' + description: "Fallback branch to use" required: true - default: 'develop' + default: "develop" type: choice options: - - develop - - main + - develop + - main jobs: - test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-24.04] - python-version: [ "3.12"] #, "3.8", "3.9", "3.10","3.11",] + python-version: ["3.12"] #, "3.8", "3.9", "3.10","3.11",] steps: # Current Branch @@ -41,33 +40,32 @@ jobs: else echo "FALLBACK_BRANCH=develop" >> $GITHUB_ENV fi - + - uses: actions/checkout@v4 with: ref: ${{ env.BRANCH_NAME }} fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version }} # Checkout Mountain Ash Dependencies - - name: Load Dependencies - id: deps - uses: ./.github/actions/load-dependencies - with: - config-path: .github/config/mountainash_dependencies.yml + # - name: Load Dependencies + # id: deps + # uses: ./.github/actions/load-dependencies + # with: + # config-path: .github/config/mountainash_dependencies.yml - - name: Checkout Dependencies - uses: ./.github/actions/checkout-dependencies - with: - dependencies: ${{ steps.deps.outputs.dependencies }} - target-branch: ${{ env.BRANCH_NAME }} - default-branch: ${{ env.FALLBACK_BRANCH }} - token: ${{ secrets.CLONE_PRIVATE_REPOS_TOKEN }} - org-name: ${{ env.ORGNAME }} + # - name: Checkout Dependencies + # uses: ./.github/actions/checkout-dependencies + # with: + # dependencies: ${{ steps.deps.outputs.dependencies }} + # target-branch: ${{ env.BRANCH_NAME }} + # default-branch: ${{ env.FALLBACK_BRANCH }} + # token: ${{ secrets.CLONE_PRIVATE_REPOS_TOKEN }} + # org-name: ${{ env.ORGNAME }} # Install Hatch - name: Install Hatch @@ -75,7 +73,7 @@ jobs: - name: Create virtual environment run: hatch env create test_github - + #Run Pytest # - name: Run tests # run: hatch run test_github:test @@ -94,4 +92,4 @@ jobs: if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: - token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/hatch.toml b/hatch.toml index 9dffa29..d3d69b5 100644 --- a/hatch.toml +++ b/hatch.toml @@ -15,7 +15,7 @@ installer = "uv" dependencies = [ "cyclonedx-bom==4.5.0", - "mountainash_constants @ {root:uri}/temp/mountainash-constants", + # "mountainash_constants @ {root:uri}/temp/mountainash-constants", # "mountainash_utils_os @ {root:uri}/temp/mountainash-utils-os", ] [envs.build_github.scripts] @@ -52,7 +52,7 @@ dependencies = [ "pytest-check==2.5.3", "pytest-cov==6.1.1", - "mountainash_constants @ {root:uri}/temp/mountainash-constants", + # "mountainash_constants @ {root:uri}/temp/mountainash-constants", # "mountainash_utils_os @ {root:uri}/temp/mountainash-utils-os", ] [envs.test_github.scripts] @@ -82,7 +82,7 @@ dependencies = [ "pytest-timeout>=2.1.0", # Test timing control "pytest-picked>=0.5.0", # Changed files testing - "mountainash_constants @ {root:uri}/../mountainash-constants", + # "mountainash_constants @ {root:uri}/../mountainash-constants", # "mountainash_utils_os @ {root:uri}/../mountainash-utils-os", From 81f4fb4c842252ba13abb714ffb859433a7812fa Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Tue, 14 Apr 2026 20:37:30 +1000 Subject: [PATCH 03/13] cleanup: remove unused imports across settings modules Co-Authored-By: Claude Opus 4.6 (1M context) --- src/mountainash_settings/settings/app/app_settings.py | 1 - src/mountainash_settings/settings/app/app_settings_templates.py | 1 - src/mountainash_settings/settings_cache/settings_manager.py | 1 - 3 files changed, 3 deletions(-) diff --git a/src/mountainash_settings/settings/app/app_settings.py b/src/mountainash_settings/settings/app/app_settings.py index b0a6dc5..ae0ddb3 100644 --- a/src/mountainash_settings/settings/app/app_settings.py +++ b/src/mountainash_settings/settings/app/app_settings.py @@ -3,7 +3,6 @@ from pydantic import Field from upath import UPath -from functools import lru_cache # from mountainash_utils_os import get_platform_slash from mountainash_settings import MountainAshBaseSettings, SettingsParameters diff --git a/src/mountainash_settings/settings/app/app_settings_templates.py b/src/mountainash_settings/settings/app/app_settings_templates.py index 3cc0d7a..fa4c151 100644 --- a/src/mountainash_settings/settings/app/app_settings_templates.py +++ b/src/mountainash_settings/settings/app/app_settings_templates.py @@ -1,7 +1,6 @@ from typing import Optional,List, Tuple from upath import UPath from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict from functools import lru_cache from mountainash_settings import MountainAshBaseSettings, SettingsParameters diff --git a/src/mountainash_settings/settings_cache/settings_manager.py b/src/mountainash_settings/settings_cache/settings_manager.py index 1d8ee5e..a3dbc83 100644 --- a/src/mountainash_settings/settings_cache/settings_manager.py +++ b/src/mountainash_settings/settings_cache/settings_manager.py @@ -1,7 +1,6 @@ from typing import Optional, Any, Type, Dict from importlib import import_module -from pydantic_settings import BaseSettings from ..settings_parameters import SettingsParameters, SettingsKwargsHandler from ..settings import MountainAshBaseSettings From aaa885aac0c62971576df93e398a50b474dcacf3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 16 Apr 2026 13:44:19 +1000 Subject: [PATCH 04/13] docs(specs): add profiles promotion design spec Promotes the descriptor/registry/auth/template patterns from mountainash-data into mountainash-settings, enabling reuse by mountainash-utils-files, mountainash-utils-secrets, and mountainash-acrds-core. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-16-profiles-promotion-design.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-16-profiles-promotion-design.md diff --git a/docs/superpowers/specs/2026-04-16-profiles-promotion-design.md b/docs/superpowers/specs/2026-04-16-profiles-promotion-design.md new file mode 100644 index 0000000..bf7cb43 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-profiles-promotion-design.md @@ -0,0 +1,192 @@ +# Profiles Promotion — Design Spec + +**Date:** 2026-04-16 +**Status:** Draft — pending implementation plan + +## Problem Statement + +The 2026-04-15 settings-registry refactor in `mountainash-data` replaced `BaseDBAuthSettings` inheritance with a declarative descriptor + registry pattern (`BackendDescriptor`, `ParameterSpec`, `ConnectionProfile`, `@register`, typed `AuthSpec` discriminated union, per-backend adapters). The result: 12 backends reduced to two-line shell classes with ~20-40 lines of descriptor data each, ~200 lines of per-backend boilerplate eliminated. + +Three other `mountainash` packages exhibit the same pre-refactor shape: + +- **`mountainash-utils-files`** — 18 storage providers (S3, Azure Blob, GCS, SFTP, FTP, NFS, etc.), each a `StorageAuthBase` subclass with 20+ inherited optional fields, hand-written `get_connection_args()` / `get_connection_url()` / `_init_provider_specific()`. +- **`mountainash-utils-secrets`** — 5 secrets providers (AWS/Azure/GCP/HashiCorp/Local), each a `SecretsAuthBase` subclass with 14+ inherited fields, repeated `init_setting_from_template()` boilerplate, per-provider `_init_dynamic_settings()` stubs. +- **`mountainash-acrds-core`** — deployable client-side app with a flat 60-field settings class; `post_init()` is a 150-line sequence of `init_setting_from_template()` calls, one per derived field. + +Without intervention, each of these packages re-invents some or all of the descriptor/registry/auth/template pattern independently. The goal is to lift the reusable machinery into `mountainash-settings` so each consumer writes only what's genuinely domain-specific. + +## Goals + +1. **Extract two reusable patterns** from `mountainash-data` into `mountainash-settings`: + - **Pattern A — Descriptor Registry:** `ProfileDescriptor`, `ParameterSpec`, `DescriptorProfile`, `Registry`, `@register`, adapter contract, full `AuthSpec` hierarchy with default dispatch. + - **Pattern B — Declarative Templates:** `ParameterSpec.template` field auto-wired through `__pydantic_init_subclass__` to replace manual `post_init()` template resolution. +2. **Migrate the four target packages** to the new location with no external API change (`mountainash-data` re-exports stay stable). +3. **Per-domain registries** so `mountainash-utils-files` registers `"s3"`, `mountainash-data` registers `"postgresql"`, `mountainash-utils-secrets` registers `"vault"`, etc., without name collisions. +4. **Domain-specific outputs stay in domain packages** — `mountainash-settings` provides mechanism only, not `to_driver_kwargs()` / `to_connection_url()` etc. + +## Non-Goals + +- Rewriting `MountainAshBaseSettings` itself (it stays as-is). +- Changing `SettingsParameters` / `SettingsManager` / `SettingsConfigDict` / existing file-based config loading. +- Adding new backends / providers — just moving the pattern, consumers migrate their existing set. +- Replacing the setattr-bypass limitation — documented as a separate backlog item; possible resolution folded into `DescriptorProfile` as a follow-up but not blocking. + +## Architecture + +### Layer 1 — `mountainash-settings` (new sub-packages) + +``` +src/mountainash_settings/ +├── profiles/ # NEW — Pattern A + B mechanism +│ ├── __init__.py # Re-exports public API +│ ├── descriptor.py # MISSING, ParameterSpec, ProfileDescriptor +│ ├── profile.py # DescriptorProfile (pure mechanism base) +│ ├── registry.py # Registry class, snapshot/reset test seams +│ └── invariants.py # descriptor_invariants_for(registry) pytest helper +└── auth/ # NEW — cross-cutting auth types + ├── __init__.py # Re-exports all 11 subclasses + AuthSpec + ├── base.py # AuthSpec (pydantic v2 BaseModel, discriminated) + ├── none.py, password.py, token.py, oauth2.py, service_account.py, + │ iam.py, azure.py, kerberos.py, certificate.py + └── dispatch.py # AUTH_TO_DRIVER_KWARGS, auth_to_driver_kwargs() +``` + +All code lifts from `mountainash-data` verbatim with a single rename: `BackendDescriptor` → `ProfileDescriptor`, to drop the database-specific connotation. + +### Layer 2 — Consumer packages (thin subclasses + per-domain registries) + +Each consumer adds two small files and keeps its domain-specific descriptors, adapters, and output methods local. + +**`mountainash-data`** (reference example, unchanged external surface): + +```python +# core/settings/profile.py +from mountainash_settings.profiles import DescriptorProfile + +class ConnectionProfile(DescriptorProfile): + """Database connection settings with driver kwargs + URL outputs.""" + def to_driver_kwargs(self) -> dict: ... # domain-specific + def to_connection_string(self) -> str: ... # domain-specific + +# core/settings/registry.py +from mountainash_settings.profiles import Registry +DATABASES_REGISTRY = Registry("databases") +register = DATABASES_REGISTRY.decorator() # bound convenience + +# core/settings/__init__.py (external API — unchanged symbols) +from .profile import ConnectionProfile +from .registry import DATABASES_REGISTRY, register +from mountainash_settings.auth import PasswordAuth, NoAuth, ... # re-export +# ... all 12 backend shells unchanged +``` + +**`mountainash-utils-files`**: +```python +class StorageProfile(DescriptorProfile): + def to_connection_args(self) -> dict: ... + def to_connection_url(self) -> str: ... + +STORAGE_REGISTRY = Registry("storage") +register = STORAGE_REGISTRY.decorator() +``` + +**`mountainash-utils-secrets`**: +```python +class SecretsProfile(DescriptorProfile): + def to_client_kwargs(self) -> dict: ... + +SECRETS_REGISTRY = Registry("secrets") +register = SECRETS_REGISTRY.decorator() +``` + +**`mountainash-acrds-core`** (Pattern B primary use case): +```python +class AppProfile(DescriptorProfile): + """App settings with declarative templates; no kwargs output needed.""" + +# Single descriptor replaces the 60-field flat class + 150-line post_init. +# ParameterSpec(template="...") wires each derived field automatically. +``` + +### Key Contracts + +**`ProfileDescriptor`** (`mountainash_settings.profiles.descriptor`) +- Frozen dataclass. Fields: `name`, `provider_type`, `parameters: list[ParameterSpec]`, `auth_modes: list[type[AuthSpec]]`. +- Optional `metadata: dict[str, Any]` for domain-specific extras (e.g. `default_port`, `connection_string_scheme` in mountainash-data). +- Domain packages can subclass: `ConnectionDescriptor(ProfileDescriptor)` adds typed `default_port: int | None`, etc. + +**`ParameterSpec`** (gains one field for Pattern B) +- Existing: `name`, `type`, `tier`, `default`, `description`, `driver_key`, `secret`, `transform`, `validator`. +- **New:** `template: str | None = None` — when set, `DescriptorProfile.__pydantic_init_subclass__` wires `init_setting_from_template()` into the profile's `post_init` automatically. + +**`DescriptorProfile`** (pure mechanism) +- `__descriptor__: ClassVar[ProfileDescriptor]` and `__adapter__: ClassVar[Callable | None] = None`. +- `__pydantic_init_subclass__` installs descriptor params as pydantic fields, composes auth discriminated union, wires templates. +- Helpers: `_default_kwargs()`, `_auth_kwargs()`, `backend` / `profile_name` / `provider_type` properties. +- **No** `to_driver_kwargs` / `to_connection_string` — those are domain subclass concerns. +- Adapter contract preserved: if set, adapter owns the full output pipeline. + +**`Registry`** (one per consumer domain) +```python +registry = Registry("databases") # name for error messages +registry.register(descriptor, cls) # direct API +decorator = registry.decorator() # @decorator(descriptor) class-decorator factory +registry.get_descriptor("postgresql") # KeyError with "known: ..." hint +registry.get_settings_class("postgresql") +registry.descriptors # read-only view +len(registry) # iteration-friendly +``` +- Includes `_snapshot_for_tests()` / `_reset_for_tests()` seams. +- Error messages include the registry name: `Backend 'foo' not registered in databases registry. Known: postgresql, ...`. + +**`descriptor_invariants_for(registry)`** — pytest helper +- Returns a parametric test class factory. Each consumer drops it into its test suite: + ```python + # tests/test_descriptor_invariants.py + TestDatabaseInvariants = descriptor_invariants_for(DATABASES_REGISTRY) + ``` +- Runs the 10 invariants (unique names/driver_keys, valid tiers, auth subclasses, port range, uppercase param names, etc.) over every descriptor in the given registry. New registrations get coverage for free. + +**Auth dispatch** (unchanged from mountainash-data) +- `auth_to_driver_kwargs(auth)` handles NoAuth / PasswordAuth / TokenAuth / JWTAuth / OAuth2Auth / IAMAuth by default. +- Adapter-handled types (ServiceAccountAuth, AzureADAuth, KerberosAuth, CertificateAuth, WindowsAuth) raise `KeyError` — domain adapters must handle them. + +## Testing Strategy + +- `mountainash-settings` gains full test suites for `profiles/` and `auth/` — lifted from `mountainash-data` and re-parented. Target ≥95% coverage on new mechanism. +- Each consumer uses `descriptor_invariants_for(THEIR_REGISTRY)` to pin descriptor correctness. New providers get coverage automatically. +- Per-consumer round-trip tests (e.g. `test_s3.py` for storage) validate adapters and audit-regression fixes — same pattern as `mountainash-data`'s 12 `test_.py` files. +- Migration step for each consumer keeps the existing test count green with zero regressions; new pattern's invariants add coverage. + +## Migration Strategy + +Sequenced for safety, each step a separate PR with tests green at each checkpoint. + +1. **Build `mountainash-settings` `profiles/` + `auth/`** — new code, no consumers yet. Verbatim lift + `BackendDescriptor` → `ProfileDescriptor` rename + `Registry` class + invariants helper. Ship first. +2. **Migrate `mountainash-data`** — delete its local `descriptor.py`/`auth/`/`profile.py`/`registry.py`. Replace with `ConnectionProfile(DescriptorProfile)` subclass + `DATABASES_REGISTRY`. `settings/__init__.py` re-exports keep external API identical. +3. **Migrate `mountainash-utils-secrets`** — smallest (5 providers); proves pattern generalizes beyond databases. +4. **Migrate `mountainash-utils-files`** — largest (18 providers); validates Pattern A at scale. +5. **Migrate `mountainash-acrds-core`** — different shape; validates Pattern B (templates). Single big descriptor replaces the 60-field flat class and 150-line `post_init`. + +## Risks + +- **Setattr-bypass limitation inherits.** `MountainAshBaseSettings.update_settings_from_dict` bypasses pydantic, so `ParameterSpec.validator` enforces rejection but not transformation, and enum-identity checks require per-class `__setattr__` overrides. Documented in `mountainash-central/01.principles/mountainash-data/f.backlog/setattr-bypass-limitation.md`. A generic enum-coercing `__setattr__` on `DescriptorProfile` would resolve it for all four packages at once — flagged as a fast-follow after initial migration. +- **`mountainash-settings` surface growth.** Adding `profiles/` + `auth/` sub-packages roughly doubles the public API. Mitigation: clean sub-package boundaries; existing consumers unaffected until they opt in. +- **Hard cutover risk.** No deprecation shims — any downstream package importing from `mountainash-data`'s internal module paths (rather than the public `core.settings.__init__`) may break. Mitigation: public re-exports stay stable; internal paths are not considered public API. +- **Per-domain registries may feel over-engineered** for the smallest consumer (5 secrets providers). Mitigation: registry is opt-in — a consumer with ≤3 providers can skip it and just subclass `DescriptorProfile` directly. +- **Pattern B less battle-tested.** Only `mountainash-acrds-core` exercises templates. Mitigation: implement Pattern A first; validate B last; if B doesn't work cleanly on acrds-core, revert and leave its current `post_init` approach intact — Pattern A migrations ship regardless. + +## File-Level Changes (Summary) + +| Repo | Net change | +|---|---| +| `mountainash-settings` | +2 sub-packages (`profiles/`, `auth/`), ~700 LOC added | +| `mountainash-data` | Internal files deleted (descriptor/auth/profile/registry); subclass + registry added; external `__init__.py` API unchanged | +| `mountainash-utils-files` | 18 provider files → descriptor literals + adapters; `StorageAuthBase` deleted | +| `mountainash-utils-secrets` | 5 provider files → descriptor literals; `SecretsAuthBase` deleted | +| `mountainash-acrds-core` | 60-field class + 150-line `post_init` → single descriptor with `template=` wiring | +| `mountainash-central` | `descriptor-based-settings` principle updated to point at mountainash-settings as the canonical home | + +## Open Questions + +None blocking — the design is fully specified. Implementation plan will decompose each migration into TDD task sequences. From 1fb1b6ce39eb4a49f9b0cdcb826ddbd9d2bef425 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 16 Apr 2026 14:08:58 +1000 Subject: [PATCH 05/13] docs(plans): add Phase 1 profiles-scaffolding implementation plan 10 tasks: port AuthSpec hierarchy (11 subclasses + dispatch), build profiles/ sub-package (descriptor + registry + DescriptorProfile + template wiring + invariants helper), wire public re-exports. Ships the reusable mechanism ahead of any consumer migration. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-04-16-profiles-scaffolding.md | 1560 +++++++++++++++++ 1 file changed, 1560 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-16-profiles-scaffolding.md diff --git a/docs/superpowers/plans/2026-04-16-profiles-scaffolding.md b/docs/superpowers/plans/2026-04-16-profiles-scaffolding.md new file mode 100644 index 0000000..59f8171 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-profiles-scaffolding.md @@ -0,0 +1,1560 @@ +# Profiles Promotion — Phase 1: `mountainash-settings` Scaffolding + +> **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:** Add `profiles/` and `auth/` sub-packages to `mountainash-settings`, lifting the descriptor/registry/auth machinery from `mountainash-data`'s 2026-04-15 settings-registry refactor. No consumers yet — this phase is pure library work, verified against its own test suite. + +**Architecture:** Two new sub-packages live under `src/mountainash_settings/`. `profiles/` contains the mechanism (`ProfileDescriptor`, `ParameterSpec`, `DescriptorProfile`, `Registry`, invariants helper). `auth/` contains the full `AuthSpec` hierarchy + default dispatch map. Each is self-contained; public re-exports happen from the package `__init__.py`. + +**Tech Stack:** Python 3.12, pydantic v2, `MountainAshBaseSettings` (existing), hatch + pytest for the test environment. + +**Reference source:** All code lifts almost verbatim from `mountainash-data` at commit `2ec5079` (tip of `feat/settings-registry`). Key source paths: +- `src/mountainash_data/core/settings/descriptor.py` +- `src/mountainash_data/core/settings/auth/` (full directory) +- `src/mountainash_data/core/settings/profile.py` +- `src/mountainash_data/core/settings/registry.py` +- `tests/test_unit/core/settings/test_descriptors_invariants.py` (template for invariants helper) + +**Spec:** `docs/superpowers/specs/2026-04-16-profiles-promotion-design.md` + +**Working directory for all commands:** `/home/nathanielramm/git/mountainash-io/mountainash/mountainash-settings` + +**Branch:** Create `feat/profiles-promotion` from `main`. + +--- + +## Task 1: Branch + `auth/` package skeleton + +**Files:** +- Create: `src/mountainash_settings/auth/__init__.py` (empty, adds `__all__` at end) +- Create: `src/mountainash_settings/auth/base.py` + +- [ ] **Step 1: Create branch** + +```bash +cd /home/nathanielramm/git/mountainash-io/mountainash/mountainash-settings +git checkout -b feat/profiles-promotion +``` + +- [ ] **Step 2: Write `auth/base.py`** + +```python +# src/mountainash_settings/auth/base.py +"""Base class for discriminated-union auth specs. + +Each AuthSpec subclass declares a ``kind: Literal["..."]`` field that pydantic +uses as the discriminator. The base class does NOT declare ``kind`` — if it +did, every subclass would trip ``reportIncompatibleVariableOverride``. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +__all__ = ["AuthSpec"] + + +class AuthSpec(BaseModel): + """Base for typed auth modes used as a pydantic discriminated union.""" + + model_config = ConfigDict(extra="forbid", frozen=True) +``` + +- [ ] **Step 3: Write a smoke test** + +```python +# tests/unit/auth/test_base.py +"""Sanity-check the AuthSpec base class.""" + +import pytest + +from mountainash_settings.auth.base import AuthSpec + + +@pytest.mark.unit +def test_authspec_is_frozen(): + class Dummy(AuthSpec): + pass + + d = Dummy() + with pytest.raises(Exception): # FrozenInstanceError/ValidationError + d.anything = 1 # type: ignore + + +@pytest.mark.unit +def test_authspec_rejects_extras(): + class Dummy(AuthSpec): + pass + + with pytest.raises(Exception): + Dummy(extra_field="nope") # type: ignore +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +hatch run test:test-target tests/unit/auth/test_base.py -v +``` + +Expected: 2 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/mountainash_settings/auth/ tests/unit/auth/ +git commit -m "feat(auth): add AuthSpec base class for discriminated unions" +``` + +--- + +## Task 2: Port all 11 AuthSpec subclasses + +**Files:** +- Create: `src/mountainash_settings/auth/none.py` +- Create: `src/mountainash_settings/auth/password.py` +- Create: `src/mountainash_settings/auth/token.py` +- Create: `src/mountainash_settings/auth/oauth2.py` +- Create: `src/mountainash_settings/auth/service_account.py` +- Create: `src/mountainash_settings/auth/iam.py` +- Create: `src/mountainash_settings/auth/azure.py` +- Create: `src/mountainash_settings/auth/kerberos.py` +- Create: `src/mountainash_settings/auth/certificate.py` +- Modify: `src/mountainash_settings/auth/__init__.py` + +- [ ] **Step 1: Copy each auth module verbatim from mountainash-data** + +The files at `/home/nathanielramm/git/mountainash-io/mountainash/mountainash-data/src/mountainash_data/core/settings/auth/*.py` are the canonical source. For each file listed above: + +```bash +cp /home/nathanielramm/git/mountainash-io/mountainash/mountainash-data/src/mountainash_data/core/settings/auth/none.py \ + src/mountainash_settings/auth/none.py +# ... repeat for each of the 9 files (base.py already exists from Task 1, so skip) +``` + +Note the module groupings — `token.py` contains both `TokenAuth` and `JWTAuth`; `azure.py` contains both `WindowsAuth` and `AzureADAuth`. + +- [ ] **Step 2: Fix the `from .base import AuthSpec` relative imports** + +They already use relative imports (`from .base import AuthSpec`) — no change needed, but verify by grepping: + +```bash +grep -l "from mountainash_data" src/mountainash_settings/auth/ +``` + +Expected: no matches. All imports should be relative (`from .base`, `from ..`). + +- [ ] **Step 3: Write `src/mountainash_settings/auth/__init__.py`** + +```python +"""Discriminated-union auth specs for settings profiles.""" + +from __future__ import annotations + +from .azure import AzureADAuth, WindowsAuth +from .base import AuthSpec +from .certificate import CertificateAuth +from .iam import IAMAuth +from .kerberos import KerberosAuth +from .none import NoAuth +from .oauth2 import OAuth2Auth +from .password import PasswordAuth +from .service_account import ServiceAccountAuth +from .token import JWTAuth, TokenAuth + +__all__ = [ + "AuthSpec", + "AzureADAuth", + "CertificateAuth", + "IAMAuth", + "JWTAuth", + "KerberosAuth", + "NoAuth", + "OAuth2Auth", + "PasswordAuth", + "ServiceAccountAuth", + "TokenAuth", + "WindowsAuth", +] +``` + +- [ ] **Step 4: Port the auth test suite** + +Copy `tests/test_unit/core/settings/test_auth.py` from mountainash-data to `tests/unit/auth/test_subclasses.py`. Update the imports from `mountainash_data.core.settings.auth` → `mountainash_settings.auth`. + +```bash +cp /home/nathanielramm/git/mountainash-io/mountainash/mountainash-data/tests/test_unit/core/settings/test_auth.py \ + tests/unit/auth/test_subclasses.py +# Then edit imports. +``` + +Use `sed` or manual edit: + +```bash +sed -i 's|mountainash_data\.core\.settings\.auth|mountainash_settings.auth|g' \ + tests/unit/auth/test_subclasses.py +``` + +- [ ] **Step 5: Run the suite** + +```bash +hatch run test:test-target tests/unit/auth/ -v +``` + +Expected: all tests pass (~18 — 2 from Task 1 + ~16 from the ported suite). + +- [ ] **Step 6: Commit** + +```bash +git add src/mountainash_settings/auth/ tests/unit/auth/ +git commit -m "feat(auth): port 11 AuthSpec subclasses from mountainash-data" +``` + +--- + +## Task 3: Auth dispatch map + +**Files:** +- Create: `src/mountainash_settings/auth/dispatch.py` +- Create: `tests/unit/auth/test_dispatch.py` + +- [ ] **Step 1: Copy dispatch.py verbatim** + +```bash +cp /home/nathanielramm/git/mountainash-io/mountainash/mountainash-data/src/mountainash_data/core/settings/auth/dispatch.py \ + src/mountainash_settings/auth/dispatch.py +``` + +- [ ] **Step 2: Verify imports are relative** + +Open the file. Ensure imports are `from .base import AuthSpec`, `from .none import NoAuth`, etc. If any reference `mountainash_data`, fix them. + +- [ ] **Step 3: Add exports to `auth/__init__.py`** + +Append to `src/mountainash_settings/auth/__init__.py`: + +```python +from .dispatch import AUTH_TO_DRIVER_KWARGS, auth_to_driver_kwargs +``` + +Append to `__all__`: + +```python + "AUTH_TO_DRIVER_KWARGS", + "auth_to_driver_kwargs", +``` + +- [ ] **Step 4: Port dispatch tests** + +```bash +cp /home/nathanielramm/git/mountainash-io/mountainash/mountainash-data/tests/test_unit/core/settings/test_auth_dispatch.py \ + tests/unit/auth/test_dispatch.py + +sed -i 's|mountainash_data\.core\.settings\.auth|mountainash_settings.auth|g' \ + tests/unit/auth/test_dispatch.py +``` + +- [ ] **Step 5: Run tests** + +```bash +hatch run test:test-target tests/unit/auth/ -v +``` + +Expected: all tests pass (ported auth + dispatch suites). + +- [ ] **Step 6: Commit** + +```bash +git add src/mountainash_settings/auth/dispatch.py \ + src/mountainash_settings/auth/__init__.py \ + tests/unit/auth/test_dispatch.py +git commit -m "feat(auth): add AUTH_TO_DRIVER_KWARGS default dispatch map" +``` + +--- + +## Task 4: `profiles/descriptor.py` — MISSING, ParameterSpec, ProfileDescriptor + +**Files:** +- Create: `src/mountainash_settings/profiles/__init__.py` (empty initially) +- Create: `src/mountainash_settings/profiles/descriptor.py` +- Create: `tests/unit/profiles/test_descriptor.py` + +- [ ] **Step 1: Write `profiles/descriptor.py`** + +```python +# src/mountainash_settings/profiles/descriptor.py +"""Declarative descriptors for settings profiles. + +A :class:`ProfileDescriptor` captures everything the generic +:class:`DescriptorProfile` base needs to install pydantic fields for a given +configuration. A :class:`ParameterSpec` describes one field within a +descriptor. + +Extends the ``BackendDescriptor`` from mountainash-data's 2026-04-15 refactor +by renaming for generality (not all profiles are for "backends") and adding +``ParameterSpec.template`` for declarative template-driven derived fields. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +__all__ = ["MISSING", "ParameterSpec", "ProfileDescriptor"] + + +class _Missing: + """Sentinel indicating a required (no-default) field. + + Pydantic ``Field(...)`` is emitted when a :class:`ParameterSpec` default + is this sentinel; ``Field(default=...)`` otherwise. + """ + + _instance: "t.ClassVar[_Missing | None]" = None + + def __new__(cls) -> "_Missing": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "MISSING" + + def __bool__(self) -> bool: + return False + + +MISSING: _Missing = _Missing() + + +@dataclass(frozen=True, kw_only=True) +class ParameterSpec: + """One settings field on a profile. + + Attributes: + name: Settings-facing uppercase name (e.g. ``"SSL_CERT"``). + type: Pydantic-compatible annotation (``str``, ``int | None``, enum, …). + tier: ``"core"`` or ``"advanced"`` — audit-style severity tier. + default: Default value; :data:`MISSING` means the field is required. + description: Optional docstring for generated schemas / help output. + driver_key: Output-kwarg name for 1:1 mappings (e.g. ``"sslcert"``). + ``None`` means a domain adapter handles emission. + secret: If ``True``, wrap ``type`` as :class:`pydantic.SecretStr` and + auto-unwrap via ``.get_secret_value()`` at the kwargs boundary. + transform: Optional callable applied when emitting kwargs. + validator: Optional pydantic-compatible field-level validator. + template: Optional template string; when set, + :class:`DescriptorProfile` auto-wires ``init_setting_from_template`` + in ``post_init`` to populate this field. + """ + + name: str + type: t.Any + tier: t.Literal["core", "advanced"] + default: t.Any = MISSING + description: str = "" + driver_key: str | None = None + secret: bool = False + transform: t.Callable[[t.Any], t.Any] | None = None + validator: t.Callable[[t.Any], t.Any] | None = None + template: str | None = None + + +@dataclass(frozen=True, kw_only=True) +class ProfileDescriptor: + """Immutable description of a single settings profile. + + Attributes: + name: Short name (conventionally lowercase, e.g. ``"postgresql"``). + provider_type: Canonical provider identifier (domain-specific enum). + parameters: Ordered list of :class:`ParameterSpec`. + auth_modes: List of :class:`AuthSpec` subclasses this profile accepts. + metadata: Bag of domain-specific metadata (e.g. port, URL scheme, + dialect name). Domains wanting strong typing may subclass + ``ProfileDescriptor`` and add typed fields instead. + """ + + name: str + provider_type: t.Any + parameters: list[ParameterSpec] + auth_modes: list[type] # list[type[AuthSpec]] — forward-refd to avoid cycle + metadata: dict[str, t.Any] = field(default_factory=dict) +``` + +- [ ] **Step 2: Write tests** + +```python +# tests/unit/profiles/test_descriptor.py +"""Unit tests for ProfileDescriptor, ParameterSpec, and MISSING.""" + +import pytest + +from mountainash_settings.auth import NoAuth +from mountainash_settings.profiles.descriptor import ( + MISSING, + ParameterSpec, + ProfileDescriptor, +) + + +@pytest.mark.unit +class TestMissing: + def test_missing_is_singleton(self): + from mountainash_settings.profiles.descriptor import _Missing + assert _Missing() is MISSING + + def test_missing_is_falsy(self): + assert not MISSING + + def test_missing_repr(self): + assert repr(MISSING) == "MISSING" + + +@pytest.mark.unit +class TestParameterSpec: + def test_minimal(self): + p = ParameterSpec(name="X", type=str, tier="core") + assert p.name == "X" + assert p.default is MISSING + assert p.template is None + + def test_frozen(self): + p = ParameterSpec(name="X", type=str, tier="core") + with pytest.raises(Exception): + p.name = "Y" # type: ignore + + def test_template_field(self): + p = ParameterSpec(name="URL", type=str, tier="core", + template="https://{HOST}/api") + assert p.template == "https://{HOST}/api" + + +@pytest.mark.unit +class TestProfileDescriptor: + def test_minimal(self): + d = ProfileDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], + ) + assert d.name == "x" + assert d.metadata == {} + + def test_metadata(self): + d = ProfileDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], + metadata={"port": 5432, "scheme": "x://"}, + ) + assert d.metadata["port"] == 5432 + + def test_frozen(self): + d = ProfileDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], + ) + with pytest.raises(Exception): + d.name = "y" # type: ignore +``` + +- [ ] **Step 3: Write empty `profiles/__init__.py`** + +```python +# src/mountainash_settings/profiles/__init__.py +"""Declarative settings profiles — descriptor + registry + generic base. + +Lifted and generalized from mountainash-data's 2026-04-15 settings-registry +refactor. See design spec: +``docs/superpowers/specs/2026-04-16-profiles-promotion-design.md``. +""" + +from __future__ import annotations + +from .descriptor import MISSING, ParameterSpec, ProfileDescriptor + +__all__ = ["MISSING", "ParameterSpec", "ProfileDescriptor"] +``` + +- [ ] **Step 4: Run tests** + +```bash +hatch run test:test-target tests/unit/profiles/test_descriptor.py -v +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/mountainash_settings/profiles/ tests/unit/profiles/ +git commit -m "feat(profiles): add ProfileDescriptor + ParameterSpec with template support" +``` + +--- + +## Task 5: `profiles/registry.py` — Registry class + +**Files:** +- Create: `src/mountainash_settings/profiles/registry.py` +- Create: `tests/unit/profiles/test_registry.py` +- Modify: `src/mountainash_settings/profiles/__init__.py` + +- [ ] **Step 1: Write `registry.py`** + +```python +# src/mountainash_settings/profiles/registry.py +"""Per-domain registry of profile descriptors and settings classes. + +Each consumer domain instantiates one :class:`Registry` with a name (used in +error messages and test IDs). Example:: + + DATABASES_REGISTRY = Registry("databases") + register = DATABASES_REGISTRY.decorator() + + @register(POSTGRESQL_DESCRIPTOR) + class PostgreSQLAuthSettings(ConnectionProfile): + __descriptor__ = POSTGRESQL_DESCRIPTOR +""" + +from __future__ import annotations + +import typing as t + +from .descriptor import ProfileDescriptor + +if t.TYPE_CHECKING: + from .profile import DescriptorProfile + +__all__ = ["Registry"] + + +T = t.TypeVar("T", bound="DescriptorProfile") + + +class Registry: + """Mutable, name-keyed store of descriptors + their settings classes.""" + + def __init__(self, name: str) -> None: + self.name = name + self._descriptors: dict[str, ProfileDescriptor] = {} + self._classes: dict[str, type["DescriptorProfile"]] = {} + + def __len__(self) -> int: + return len(self._descriptors) + + def __contains__(self, name: str) -> bool: + return name in self._descriptors + + @property + def descriptors(self) -> dict[str, ProfileDescriptor]: + """Read-only view of the descriptor dict.""" + return dict(self._descriptors) + + def register( + self, + descriptor: ProfileDescriptor, + cls: type["DescriptorProfile"], + ) -> None: + """Register ``cls`` under ``descriptor.name``. + + Raises: + ValueError: if ``descriptor.name`` is already registered. + """ + if descriptor.name in self._descriptors: + existing = self._classes.get(descriptor.name) + where = ( + f"{existing.__module__}.{existing.__qualname__}" + if existing is not None + else "" + ) + raise ValueError( + f"Profile {descriptor.name!r} is already registered " + f"in {self.name} registry by {where}" + ) + self._descriptors[descriptor.name] = descriptor + self._classes[descriptor.name] = cls + cls.__descriptor__ = descriptor # belt-and-braces + + def decorator( + self, + ) -> t.Callable[[ProfileDescriptor], t.Callable[[type[T]], type[T]]]: + """Return a bound ``@register(descriptor)`` class decorator.""" + + def _factory(descriptor: ProfileDescriptor) -> t.Callable[[type[T]], type[T]]: + def _wrap(cls: type[T]) -> type[T]: + self.register(descriptor, cls) + return cls + return _wrap + + return _factory + + def get_descriptor(self, name: str) -> ProfileDescriptor: + """Return the descriptor for ``name``. + + Raises: + KeyError: with a hint listing known names. + """ + try: + return self._descriptors[name] + except KeyError: + known = ", ".join(sorted(self._descriptors)) or "" + raise KeyError( + f"No profile registered under {name!r} in {self.name} " + f"registry. Known: {known}" + ) from None + + def get_settings_class(self, name: str) -> type["DescriptorProfile"]: + """Return the settings class for ``name``. + + Raises: + KeyError: with a hint listing known names. + """ + try: + return self._classes[name] + except KeyError: + known = ", ".join(sorted(self._classes)) or "" + raise KeyError( + f"No settings class registered under {name!r} in " + f"{self.name} registry. Known: {known}" + ) from None + + # --- Test seams --------------------------------------------------------- + + def _snapshot_for_tests( + self, + ) -> tuple[ + dict[str, ProfileDescriptor], + dict[str, type["DescriptorProfile"]], + ]: + """Snapshot for later :meth:`_reset_for_tests` restore.""" + return self._descriptors.copy(), self._classes.copy() + + def _reset_for_tests( + self, + descriptors_snapshot: dict[str, ProfileDescriptor], + classes_snapshot: dict[str, type["DescriptorProfile"]], + ) -> None: + """Restore descriptors + classes dicts to snapshots (test-only).""" + self._descriptors.clear() + self._descriptors.update(descriptors_snapshot) + self._classes.clear() + self._classes.update(classes_snapshot) +``` + +- [ ] **Step 2: Write tests** + +```python +# tests/unit/profiles/test_registry.py +"""Unit tests for the Registry class.""" + +import pytest + +from mountainash_settings.auth import NoAuth +from mountainash_settings.profiles.descriptor import ProfileDescriptor +from mountainash_settings.profiles.registry import Registry + + +def _make_desc(name: str) -> ProfileDescriptor: + return ProfileDescriptor( + name=name, provider_type=name, parameters=[], auth_modes=[NoAuth], + ) + + +@pytest.mark.unit +class TestRegistry: + def test_register_inserts(self): + reg = Registry("test") + desc = _make_desc("foo") + + register = reg.decorator() + + @register(desc) + class Foo: + pass + + assert "foo" in reg + assert reg.get_descriptor("foo") is desc + assert reg.get_settings_class("foo") is Foo + + def test_duplicate_raises(self): + reg = Registry("test") + desc1 = _make_desc("dup") + desc2 = _make_desc("dup") + register = reg.decorator() + + @register(desc1) + class First: + pass + + with pytest.raises(ValueError, match="already registered"): + @register(desc2) + class Second: + pass + + def test_get_descriptor_unknown_hints(self): + reg = Registry("storage") + desc = _make_desc("s3") + register = reg.decorator() + + @register(desc) + class S3: + pass + + with pytest.raises(KeyError, match="Known: s3"): + reg.get_descriptor("not_a_real_one") + + def test_get_settings_class_unknown_hints(self): + reg = Registry("storage") + with pytest.raises(KeyError, match="Known: "): + reg.get_settings_class("nope") + + def test_duplicate_does_not_pollute_classes(self): + reg = Registry("test") + desc1 = _make_desc("inv") + desc2 = _make_desc("inv") + register = reg.decorator() + + @register(desc1) + class First: + pass + + with pytest.raises(ValueError): + @register(desc2) + class Second: + pass + + assert reg.get_settings_class("inv") is First + assert reg.get_descriptor("inv") is desc1 + + def test_snapshot_and_reset(self): + reg = Registry("t") + desc = _make_desc("x") + reg.decorator()(desc)(type("X", (), {})) + snap = reg._snapshot_for_tests() + + reg.decorator()(_make_desc("y"))(type("Y", (), {})) + assert "y" in reg + + reg._reset_for_tests(*snap) + assert "y" not in reg + assert "x" in reg + + def test_descriptors_view_is_copy(self): + reg = Registry("t") + desc = _make_desc("a") + reg.decorator()(desc)(type("A", (), {})) + + view = reg.descriptors + view["fake"] = desc # mutating the view does not affect the registry + assert "fake" not in reg +``` + +- [ ] **Step 3: Update `profiles/__init__.py`** + +Replace contents with: + +```python +# src/mountainash_settings/profiles/__init__.py +"""Declarative settings profiles — descriptor + registry + generic base.""" + +from __future__ import annotations + +from .descriptor import MISSING, ParameterSpec, ProfileDescriptor +from .registry import Registry + +__all__ = ["MISSING", "ParameterSpec", "ProfileDescriptor", "Registry"] +``` + +- [ ] **Step 4: Run tests** + +```bash +hatch run test:test-target tests/unit/profiles/ -v +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/mountainash_settings/profiles/registry.py \ + src/mountainash_settings/profiles/__init__.py \ + tests/unit/profiles/test_registry.py +git commit -m "feat(profiles): add per-domain Registry class with bound decorator" +``` + +--- + +## Task 6: `profiles/profile.py` — DescriptorProfile (Pattern A mechanism) + +**Files:** +- Create: `src/mountainash_settings/profiles/profile.py` +- Create: `tests/unit/profiles/test_profile.py` +- Modify: `src/mountainash_settings/profiles/__init__.py` + +- [ ] **Step 1: Write `profile.py`** + +Lift the implementation from `mountainash-data`'s `src/mountainash_data/core/settings/profile.py` (commit `2ec5079`). Apply these transformations: + +1. Rename class `ConnectionProfile` → `DescriptorProfile`. +2. Drop `to_driver_kwargs()` and `to_connection_string()` methods entirely (those stay in domain subclasses). +3. Rename `_default_driver_kwargs()` → `_default_kwargs()`. +4. Rename `_auth_to_driver_kwargs()` → `_auth_kwargs()`. +5. Drop the `from .auth.dispatch import auth_to_driver_kwargs` import and re-point it: `from mountainash_settings.auth import auth_to_driver_kwargs`. +6. Drop the `from ..constants` import (not needed at this layer). +7. Keep `__adapter__` contract and MRO walk verbatim. +8. Keep the `__pydantic_init_subclass__` hook verbatim, except: add template wiring (done in Task 7). + +Write this full file: + +```python +# src/mountainash_settings/profiles/profile.py +"""Generic DescriptorProfile base for declarative settings profiles. + +A subclass declares ``__descriptor__`` (a :class:`ProfileDescriptor`); this +base uses pydantic v2's ``__pydantic_init_subclass__`` hook to materialize the +descriptor into pydantic fields and compose the :class:`AuthSpec` union into +the ``auth`` field. Consumers add their own domain-specific output methods +(e.g. ``to_driver_kwargs()``) in their own subclasses. +""" + +from __future__ import annotations + +import typing as t + +from pydantic import AfterValidator, SecretStr +from pydantic.fields import FieldInfo + +from mountainash_settings import MountainAshBaseSettings +from mountainash_settings.auth import auth_to_driver_kwargs + +from .descriptor import MISSING, ProfileDescriptor + +__all__ = ["DescriptorProfile"] + + +class DescriptorProfile(MountainAshBaseSettings): + """Declarative settings base — subclasses set ``__descriptor__`` only. + + Public contract: + - :attr:`backend` / :attr:`profile_name` — descriptor name. + - :attr:`provider_type` — descriptor provider_type. + - :meth:`_default_kwargs` — 1:1 ``driver_key`` mappings from the descriptor. + - :meth:`_auth_kwargs` — default auth dispatch (consumers may override). + - ``__adapter__`` — if set, adapter owns the output pipeline. + """ + + __descriptor__: t.ClassVar[ProfileDescriptor] + __adapter__: t.ClassVar[ + t.Callable[["DescriptorProfile"], dict[str, t.Any]] | None + ] = None + + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: t.Any) -> None: + """Install fields described by ``__descriptor__`` on the subclass.""" + super().__pydantic_init_subclass__(**kwargs) + desc = cls.__dict__.get("__descriptor__") + if desc is None: + return # intermediate subclass without its own descriptor + + new_fields: dict[str, tuple[t.Any, FieldInfo]] = {} + + # 1. Descriptor parameters → pydantic fields + for spec in desc.parameters: + ptype: t.Any = SecretStr if spec.secret else spec.type + if spec.validator is not None: + ptype = t.Annotated[ptype, AfterValidator(spec.validator)] + if spec.default is MISSING: + info = FieldInfo( + annotation=ptype, + default=..., + description=spec.description, + ) + else: + info = FieldInfo( + annotation=ptype, + default=spec.default, + description=spec.description, + ) + new_fields[spec.name] = (ptype, info) + + # 2. auth field as discriminated union of descriptor.auth_modes + if desc.auth_modes: + auth_union: t.Any + if len(desc.auth_modes) == 1: + auth_union = desc.auth_modes[0] + auth_info = FieldInfo(annotation=auth_union, default=...) + else: + auth_union = t.Union[tuple(desc.auth_modes)] # type: ignore[valid-type] + auth_info = FieldInfo( + annotation=auth_union, + default=..., + discriminator="kind", + ) + new_fields["auth"] = (auth_union, auth_info) + + for name, (annotation, info) in new_fields.items(): + cls.model_fields[name] = info + cls.__annotations__[name] = annotation + + cls.model_rebuild(force=True) + + # --- Public properties --------------------------------------------------- + + @property + def profile_name(self) -> str: + return self.__descriptor__.name + + @property + def backend(self) -> str: + """Alias for ``profile_name`` — preserves naming from mountainash-data.""" + return self.__descriptor__.name + + @property + def provider_type(self) -> t.Any: + return self.__descriptor__.provider_type + + # --- Kwargs helpers ------------------------------------------------------ + + def _default_kwargs(self) -> dict[str, t.Any]: + """Emit 1:1 ``driver_key`` mappings from the descriptor. + + - Skips ``None`` values. + - Unwraps :class:`SecretStr` via ``.get_secret_value()``. + - Applies ``ParameterSpec.transform`` if set. + """ + out: dict[str, t.Any] = {} + for spec in self.__descriptor__.parameters: + if spec.driver_key is None: + continue + val = getattr(self, spec.name, None) + if val is None: + continue + # Accommodates both pydantic-coerced (SecretStr) and + # setattr-bypass (raw str) construction paths. + if isinstance(val, SecretStr): + val = val.get_secret_value() + if spec.transform is not None: + val = spec.transform(val) + out[spec.driver_key] = val + return out + + def _auth_kwargs(self) -> dict[str, t.Any]: + """Default auth dispatch. Domain adapters typically override.""" + auth = getattr(self, "auth", None) + if auth is None: + return {} + return auth_to_driver_kwargs(auth) +``` + +- [ ] **Step 2: Write tests** + +```python +# tests/unit/profiles/test_profile.py +"""Unit tests for the generic DescriptorProfile base.""" + +from __future__ import annotations + +import pytest +from pydantic import SecretStr, ValidationError + +from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_settings.profiles import ( + DescriptorProfile, + ParameterSpec, + ProfileDescriptor, +) + + +DUMMY_DESCRIPTOR = ProfileDescriptor( + name="dummy", + provider_type="dummy", + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), + ParameterSpec(name="PORT", type=int, tier="core", default=9999, driver_key="port"), + ParameterSpec(name="PASSWORD", type=str, tier="core", secret=True, + driver_key="password", default=None), + ], + auth_modes=[NoAuth, PasswordAuth], +) + + +class DummyProfile(DescriptorProfile): + __descriptor__ = DUMMY_DESCRIPTOR + + +@pytest.mark.unit +class TestDescriptorProfile: + def test_required_field_enforced(self): + with pytest.raises(ValidationError): + DummyProfile(auth=NoAuth()) # HOST missing + + def test_default_used(self): + p = DummyProfile(HOST="localhost", auth=NoAuth()) + assert p.PORT == 9999 + + def test_default_kwargs_noauth(self): + p = DummyProfile(HOST="h", PORT=1234, auth=NoAuth()) + assert p._default_kwargs() == {"host": "h", "port": 1234} + + def test_auth_kwargs_password(self): + p = DummyProfile( + HOST="h", + auth=PasswordAuth(username="u", password=SecretStr("p")), + ) + kwargs = p._auth_kwargs() + assert kwargs["user"] == "u" + assert kwargs["password"] == "p" + + def test_secret_field_unwrapped(self): + p = DummyProfile(HOST="h", PASSWORD="literal-secret", auth=NoAuth()) + kwargs = p._default_kwargs() + assert kwargs["password"] == "literal-secret" + + def test_none_values_skipped(self): + p = DummyProfile(HOST="h", auth=NoAuth()) + kwargs = p._default_kwargs() + assert "password" not in kwargs + + def test_backend_and_profile_name(self): + p = DummyProfile(HOST="h", auth=NoAuth()) + assert p.backend == "dummy" + assert p.profile_name == "dummy" + + def test_provider_type_property(self): + p = DummyProfile(HOST="h", auth=NoAuth()) + assert p.provider_type == "dummy" + + def test_transform_applied(self): + desc = ProfileDescriptor( + name="tf", provider_type="tf", auth_modes=[NoAuth], + parameters=[ + ParameterSpec( + name="FLAG", type=bool, tier="core", + default=True, driver_key="flag", + transform=lambda v: 1 if v else 0, + ), + ], + ) + + class P(DescriptorProfile): + __descriptor__ = desc + + assert P(auth=NoAuth())._default_kwargs() == {"flag": 1} + assert P(FLAG=False, auth=NoAuth())._default_kwargs() == {"flag": 0} + + def test_validator_rejects_bad_input(self): + def _positive(v: int) -> int: + if v <= 0: + raise ValueError("must be positive") + return v + + desc = ProfileDescriptor( + name="val", provider_type="val", auth_modes=[NoAuth], + parameters=[ + ParameterSpec(name="N", type=int, tier="core", + validator=_positive), + ], + ) + + class P(DescriptorProfile): + __descriptor__ = desc + + assert P(N=5, auth=NoAuth()).N == 5 + with pytest.raises(ValidationError, match="must be positive"): + P(N=-1, auth=NoAuth()) + + def test_adapter_owns_pipeline(self): + def _adapter(profile: "DescriptorProfile") -> dict: + kwargs = profile._default_kwargs() + kwargs["adapter_added"] = True + return kwargs + + class Adapted(DescriptorProfile): + __descriptor__ = DUMMY_DESCRIPTOR + __adapter__ = staticmethod(_adapter) + + # Note: DescriptorProfile itself has no to_driver_kwargs; adapters + # are invoked by domain subclasses. We test the mechanism indirectly + # by confirming the adapter attr is accessible. + p = Adapted(HOST="h", auth=NoAuth()) + assert type(p).__dict__.get("__adapter__") is not None +``` + +- [ ] **Step 3: Update `profiles/__init__.py`** + +Replace with: + +```python +# src/mountainash_settings/profiles/__init__.py +"""Declarative settings profiles — descriptor + registry + generic base.""" + +from __future__ import annotations + +from .descriptor import MISSING, ParameterSpec, ProfileDescriptor +from .profile import DescriptorProfile +from .registry import Registry + +__all__ = [ + "MISSING", + "DescriptorProfile", + "ParameterSpec", + "ProfileDescriptor", + "Registry", +] +``` + +- [ ] **Step 4: Run tests** + +```bash +hatch run test:test-target tests/unit/profiles/ -v +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/mountainash_settings/profiles/profile.py \ + src/mountainash_settings/profiles/__init__.py \ + tests/unit/profiles/test_profile.py +git commit -m "feat(profiles): add DescriptorProfile base (Pattern A mechanism)" +``` + +--- + +## Task 7: Wire `ParameterSpec.template` into `__pydantic_init_subclass__` (Pattern B) + +**Files:** +- Modify: `src/mountainash_settings/profiles/profile.py` +- Modify: `tests/unit/profiles/test_profile.py` + +**Context:** `MountainAshBaseSettings` provides `init_setting_from_template(template_str, current_value, reinitialise)` for template-driven field population. Consumers (especially `mountainash-acrds-core`) currently call this manually in a large `post_init()`. This task makes it declarative via `ParameterSpec.template`. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/unit/profiles/test_profile.py`: + +```python + def test_template_populates_derived_field(self): + """ParameterSpec(template=...) auto-populates field in post_init.""" + desc = ProfileDescriptor( + name="tmpl", provider_type="tmpl", auth_modes=[NoAuth], + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core"), + ParameterSpec(name="URL", type=str, tier="core", + default="", + template="https://{HOST}/api"), + ], + ) + + class P(DescriptorProfile): + __descriptor__ = desc + + p = P(HOST="example.com", auth=NoAuth()) + assert p.URL == "https://example.com/api" + + def test_template_respects_explicit_value(self): + """If caller sets URL explicitly, the template does not overwrite.""" + desc = ProfileDescriptor( + name="tmpl2", provider_type="tmpl2", auth_modes=[NoAuth], + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core"), + ParameterSpec(name="URL", type=str, tier="core", + default="", + template="https://{HOST}/api"), + ], + ) + + class P(DescriptorProfile): + __descriptor__ = desc + + p = P(HOST="a.b", URL="https://override.example/", auth=NoAuth()) + assert p.URL == "https://override.example/" +``` + +- [ ] **Step 2: Run to verify failure** + +```bash +hatch run test:test-target tests/unit/profiles/test_profile.py::TestDescriptorProfile::test_template_populates_derived_field -v +``` + +Expected: FAIL — `URL` is empty because templates are not wired yet. + +- [ ] **Step 3: Extend `profile.py` with template handling** + +Add this override to the `DescriptorProfile` class (after the properties, before `_default_kwargs`): + +```python + # --- Template wiring ----------------------------------------------------- + + def post_init(self, reinitialise: bool = False) -> None: + """Resolve any ``ParameterSpec.template`` fields. + + Runs ``init_setting_from_template`` for each parameter with a + template string. Respects explicit user-provided values — templates + only populate fields that match their declared default. + """ + super().post_init(reinitialise=reinitialise) + desc = type(self).__dict__.get("__descriptor__") + if desc is None: + for base in type(self).__mro__[1:]: + cand = base.__dict__.get("__descriptor__") + if cand is not None: + desc = cand + break + if desc is None: + return + for spec in desc.parameters: + if spec.template is None: + continue + current = getattr(self, spec.name, None) + new_val = self.init_setting_from_template( + template_str=spec.template, + current_value=current, + reinitialise=reinitialise, + ) + # setattr rather than assignment — field already exists + object.__setattr__(self, spec.name, new_val) +``` + +**Verify:** the `MountainAshBaseSettings.init_setting_from_template` signature is `(template_str, current_value, reinitialise)`. If it differs, adjust. The implementation is expected to return `current_value` unchanged when `current_value` differs from the declared default (i.e. caller-provided values win). If it does NOT behave that way, add an explicit check: + +```python + spec_default = spec.default if spec.default is not MISSING else None + if current not in (spec_default, None, ""): + continue # caller provided an explicit value +``` + +- [ ] **Step 4: Run tests** + +```bash +hatch run test:test-target tests/unit/profiles/ -v +``` + +Expected: all tests pass, including the two new template tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/mountainash_settings/profiles/profile.py \ + tests/unit/profiles/test_profile.py +git commit -m "feat(profiles): wire ParameterSpec.template via post_init (Pattern B)" +``` + +--- + +## Task 8: `profiles/invariants.py` — parametric test helper + +**Files:** +- Create: `src/mountainash_settings/profiles/invariants.py` +- Create: `tests/unit/profiles/test_invariants.py` +- Modify: `src/mountainash_settings/profiles/__init__.py` + +- [ ] **Step 1: Write `invariants.py`** + +```python +# src/mountainash_settings/profiles/invariants.py +"""Parametric descriptor invariants runnable against any Registry. + +Each consumer domain drops this helper into its test suite:: + + from mountainash_settings.profiles import descriptor_invariants_for + from my_package.settings import MY_REGISTRY + + TestMyInvariants = descriptor_invariants_for(MY_REGISTRY) + +Every descriptor registered in ``MY_REGISTRY`` is then checked against the +invariants below. New registrations get coverage for free. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from mountainash_settings.auth.base import AuthSpec + +from .registry import Registry + +__all__ = ["descriptor_invariants_for"] + + +def descriptor_invariants_for(registry: Registry) -> type: + """Return a pytest class parameterised over every descriptor in ``registry``. + + The returned class is named ``TestDescriptorInvariants_``. + """ + + entries = list(registry.descriptors.items()) + ids = list(registry.descriptors.keys()) or [""] + + @pytest.mark.unit + @pytest.mark.parametrize("name,descriptor", entries, ids=ids) + class TestDescriptorInvariants: # noqa: D401 + """Invariants every registered descriptor must satisfy.""" + + def test_name_matches_registry_key(self, name: str, descriptor: t.Any) -> None: + assert descriptor.name == name + + def test_name_lowercase_nonempty(self, name: str, descriptor: t.Any) -> None: + assert descriptor.name, f"{name}: descriptor.name is empty" + assert descriptor.name == descriptor.name.lower(), ( + f"{name}: descriptor.name must be lowercase" + ) + + def test_parameter_names_unique(self, name: str, descriptor: t.Any) -> None: + names = [p.name for p in descriptor.parameters] + assert len(names) == len(set(names)), f"duplicate param in {name}" + + def test_parameter_names_uppercase(self, name: str, descriptor: t.Any) -> None: + for p in descriptor.parameters: + assert p.name == p.name.upper(), ( + f"{name}.{p.name}: ParameterSpec.name must be UPPERCASE" + ) + assert p.name, f"{name}: ParameterSpec.name is empty" + + def test_driver_keys_unique(self, name: str, descriptor: t.Any) -> None: + keys = [p.driver_key for p in descriptor.parameters if p.driver_key] + assert len(keys) == len(set(keys)), f"duplicate driver_key in {name}" + + def test_parameter_tiers_valid(self, name: str, descriptor: t.Any) -> None: + for p in descriptor.parameters: + assert p.tier in {"core", "advanced"}, ( + f"{name}.{p.name} has invalid tier {p.tier!r}" + ) + + def test_auth_modes_nonempty(self, name: str, descriptor: t.Any) -> None: + assert descriptor.auth_modes, ( + f"{name}: auth_modes is empty — use [NoAuth] for no-auth profiles" + ) + + def test_auth_modes_are_authspec(self, name: str, descriptor: t.Any) -> None: + for mode in descriptor.auth_modes: + assert issubclass(mode, AuthSpec), ( + f"{name}.auth_modes contains non-AuthSpec: {mode}" + ) + + def test_provider_type_not_none(self, name: str, descriptor: t.Any) -> None: + assert descriptor.provider_type is not None, ( + f"{name} has no provider_type" + ) + + TestDescriptorInvariants.__name__ = f"TestDescriptorInvariants_{registry.name}" + TestDescriptorInvariants.__qualname__ = TestDescriptorInvariants.__name__ + return TestDescriptorInvariants +``` + +- [ ] **Step 2: Write a test that uses it against a fake registry** + +```python +# tests/unit/profiles/test_invariants.py +"""Exercise descriptor_invariants_for against a fake registry.""" + +import pytest + +from mountainash_settings.auth import NoAuth +from mountainash_settings.profiles import ( + ParameterSpec, + ProfileDescriptor, + Registry, +) +from mountainash_settings.profiles.invariants import descriptor_invariants_for + + +FAKE_REGISTRY = Registry("fake_tests") +FAKE_DESC = ProfileDescriptor( + name="fake", + provider_type="fake", + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), + ], + auth_modes=[NoAuth], +) + + +class _FakeProfile: + pass + + +FAKE_REGISTRY.register(FAKE_DESC, _FakeProfile) # type: ignore[arg-type] + + +# Dynamic class — pytest collects its parameterized methods: +TestFakeInvariants = descriptor_invariants_for(FAKE_REGISTRY) + + +@pytest.mark.unit +def test_invariants_class_is_renamed(): + """Sanity check on the name-mangling helper.""" + cls = descriptor_invariants_for(Registry("empty")) + assert cls.__name__ == "TestDescriptorInvariants_empty" +``` + +- [ ] **Step 3: Update `profiles/__init__.py`** + +Replace with final version: + +```python +# src/mountainash_settings/profiles/__init__.py +"""Declarative settings profiles — descriptor + registry + generic base.""" + +from __future__ import annotations + +from .descriptor import MISSING, ParameterSpec, ProfileDescriptor +from .invariants import descriptor_invariants_for +from .profile import DescriptorProfile +from .registry import Registry + +__all__ = [ + "MISSING", + "DescriptorProfile", + "ParameterSpec", + "ProfileDescriptor", + "Registry", + "descriptor_invariants_for", +] +``` + +- [ ] **Step 4: Run tests** + +```bash +hatch run test:test-target tests/unit/profiles/ -v +``` + +Expected: fake-invariants tests pass (9 × 1 entry = 9 parametric cases + rename smoke test). + +- [ ] **Step 5: Commit** + +```bash +git add src/mountainash_settings/profiles/invariants.py \ + src/mountainash_settings/profiles/__init__.py \ + tests/unit/profiles/test_invariants.py +git commit -m "feat(profiles): add descriptor_invariants_for pytest helper" +``` + +--- + +## Task 9: Public `mountainash_settings.__init__.py` re-exports + +**Files:** +- Modify: `src/mountainash_settings/__init__.py` + +- [ ] **Step 1: Read current `__init__.py`** + +```bash +cat src/mountainash_settings/__init__.py +``` + +Identify the existing `__all__` and import block. + +- [ ] **Step 2: Append new exports** + +Add below the existing exports (keep everything that's already there): + +```python +# --- Profiles + auth (2026-04-16 promotion) --------------------------------- + +from .profiles import ( + MISSING, + DescriptorProfile, + ParameterSpec, + ProfileDescriptor, + Registry, + descriptor_invariants_for, +) +from .auth import ( + AUTH_TO_DRIVER_KWARGS, + AuthSpec, + AzureADAuth, + CertificateAuth, + IAMAuth, + JWTAuth, + KerberosAuth, + NoAuth, + OAuth2Auth, + PasswordAuth, + ServiceAccountAuth, + TokenAuth, + WindowsAuth, + auth_to_driver_kwargs, +) +``` + +Extend the `__all__` list with those same names. + +- [ ] **Step 3: Smoke-test the public API** + +```python +# tests/unit/test_public_api.py +"""Smoke test: the new profiles/auth surface is importable from the package root.""" + +import pytest + + +@pytest.mark.unit +def test_profiles_surface_imports(): + from mountainash_settings import ( + MISSING, + DescriptorProfile, + ParameterSpec, + ProfileDescriptor, + Registry, + descriptor_invariants_for, + ) + assert all(obj is not None for obj in ( + MISSING, DescriptorProfile, ParameterSpec, + ProfileDescriptor, Registry, descriptor_invariants_for, + )) + + +@pytest.mark.unit +def test_auth_surface_imports(): + from mountainash_settings import ( + AuthSpec, NoAuth, PasswordAuth, TokenAuth, JWTAuth, OAuth2Auth, + ServiceAccountAuth, IAMAuth, WindowsAuth, AzureADAuth, KerberosAuth, + CertificateAuth, auth_to_driver_kwargs, AUTH_TO_DRIVER_KWARGS, + ) + assert issubclass(PasswordAuth, AuthSpec) + assert callable(auth_to_driver_kwargs) +``` + +- [ ] **Step 4: Run the full suite** + +```bash +hatch run test:test-target tests/unit/ -v +``` + +Expected: all tests pass (profiles + auth + public API). + +- [ ] **Step 5: Commit** + +```bash +git add src/mountainash_settings/__init__.py tests/unit/test_public_api.py +git commit -m "feat(settings): export profiles + auth from package root" +``` + +--- + +## Task 10: Push branch + open PR + +- [ ] **Step 1: Push and open PR** + +```bash +git push -u origin feat/profiles-promotion +``` + +Open a PR against `main` titled `Profiles promotion — Phase 1: mountainash-settings scaffolding`. PR body should link to: + +- Design spec: `docs/superpowers/specs/2026-04-16-profiles-promotion-design.md` +- Phase 2 plan (mountainash-data migration) — will be opened separately once Phase 1 merges. + +After merge, cut a `mountainash-settings` release (calver bump) so `mountainash-data` can pin it in Phase 2. + +--- + +## Self-Review Notes + +- **Spec coverage:** All spec sections mapped to tasks. `profiles/descriptor.py` → Task 4; `profiles/profile.py` → Tasks 6–7; `profiles/registry.py` → Task 5; `profiles/invariants.py` → Task 8; `auth/` full hierarchy → Tasks 1–3; public re-exports → Task 9. +- **Placeholder scan:** No "TBD"s or narrative-only steps. +- **Type consistency:** `DescriptorProfile` / `ProfileDescriptor` / `ParameterSpec` / `Registry` / `MISSING` names consistent across all tasks. +- **Known limitation (setattr-bypass):** Documented in the module docstring of `profiles/profile.py`. Resolution deferred — tracked in `mountainash-central/01.principles/mountainash-data/f.backlog/setattr-bypass-limitation.md`. From ad50e8f6f99c2efa38c3a272334d82d21a2fc22e Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 16 Apr 2026 14:23:11 +1000 Subject: [PATCH 06/13] feat(auth): add AuthSpec base class for discriminated unions Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_settings/auth/__init__.py | 3 +++ src/mountainash_settings/auth/base.py | 19 ++++++++++++++++++ tests/unit/__init__.py | 0 tests/unit/auth/__init__.py | 0 tests/unit/auth/test_base.py | 24 +++++++++++++++++++++++ 5 files changed, 46 insertions(+) create mode 100644 src/mountainash_settings/auth/__init__.py create mode 100644 src/mountainash_settings/auth/base.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/auth/__init__.py create mode 100644 tests/unit/auth/test_base.py diff --git a/src/mountainash_settings/auth/__init__.py b/src/mountainash_settings/auth/__init__.py new file mode 100644 index 0000000..17ba570 --- /dev/null +++ b/src/mountainash_settings/auth/__init__.py @@ -0,0 +1,3 @@ +"""Auth specs for mountainash-settings (stub — populated in later tasks).""" + +__all__: list[str] = [] diff --git a/src/mountainash_settings/auth/base.py b/src/mountainash_settings/auth/base.py new file mode 100644 index 0000000..515d734 --- /dev/null +++ b/src/mountainash_settings/auth/base.py @@ -0,0 +1,19 @@ +# src/mountainash_settings/auth/base.py +"""Base class for discriminated-union auth specs. + +Each AuthSpec subclass declares a ``kind: Literal["..."]`` field that pydantic +uses as the discriminator. The base class does NOT declare ``kind`` — if it +did, every subclass would trip ``reportIncompatibleVariableOverride``. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +__all__ = ["AuthSpec"] + + +class AuthSpec(BaseModel): + """Base for typed auth modes used as a pydantic discriminated union.""" + + model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/auth/__init__.py b/tests/unit/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/auth/test_base.py b/tests/unit/auth/test_base.py new file mode 100644 index 0000000..f76f589 --- /dev/null +++ b/tests/unit/auth/test_base.py @@ -0,0 +1,24 @@ +"""Sanity-check the AuthSpec base class.""" + +import pytest + +from mountainash_settings.auth.base import AuthSpec + + +@pytest.mark.unit +def test_authspec_is_frozen(): + class Dummy(AuthSpec): + pass + + d = Dummy() + with pytest.raises(Exception): # FrozenInstanceError/ValidationError + d.anything = 1 # type: ignore + + +@pytest.mark.unit +def test_authspec_rejects_extras(): + class Dummy(AuthSpec): + pass + + with pytest.raises(Exception): + Dummy(extra_field="nope") # type: ignore From 19e30d6e5d891987b3515c912ca066e485a158e2 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Thu, 16 Apr 2026 15:47:28 +1000 Subject: [PATCH 07/13] feat(auth): port 11 AuthSpec subclasses from mountainash-data Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_settings/auth/__init__.py | 30 ++++++- src/mountainash_settings/auth/azure.py | 30 +++++++ src/mountainash_settings/auth/certificate.py | 21 +++++ src/mountainash_settings/auth/iam.py | 22 +++++ src/mountainash_settings/auth/kerberos.py | 19 +++++ src/mountainash_settings/auth/none.py | 15 ++++ src/mountainash_settings/auth/oauth2.py | 23 +++++ src/mountainash_settings/auth/password.py | 19 +++++ .../auth/service_account.py | 18 ++++ src/mountainash_settings/auth/token.py | 25 ++++++ tests/unit/auth/test_subclasses.py | 83 +++++++++++++++++++ 11 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 src/mountainash_settings/auth/azure.py create mode 100644 src/mountainash_settings/auth/certificate.py create mode 100644 src/mountainash_settings/auth/iam.py create mode 100644 src/mountainash_settings/auth/kerberos.py create mode 100644 src/mountainash_settings/auth/none.py create mode 100644 src/mountainash_settings/auth/oauth2.py create mode 100644 src/mountainash_settings/auth/password.py create mode 100644 src/mountainash_settings/auth/service_account.py create mode 100644 src/mountainash_settings/auth/token.py create mode 100644 tests/unit/auth/test_subclasses.py diff --git a/src/mountainash_settings/auth/__init__.py b/src/mountainash_settings/auth/__init__.py index 17ba570..f123ba2 100644 --- a/src/mountainash_settings/auth/__init__.py +++ b/src/mountainash_settings/auth/__init__.py @@ -1,3 +1,29 @@ -"""Auth specs for mountainash-settings (stub — populated in later tasks).""" +"""Discriminated-union auth specs for settings profiles.""" -__all__: list[str] = [] +from __future__ import annotations + +from .azure import AzureADAuth, WindowsAuth +from .base import AuthSpec +from .certificate import CertificateAuth +from .iam import IAMAuth +from .kerberos import KerberosAuth +from .none import NoAuth +from .oauth2 import OAuth2Auth +from .password import PasswordAuth +from .service_account import ServiceAccountAuth +from .token import JWTAuth, TokenAuth + +__all__ = [ + "AuthSpec", + "AzureADAuth", + "CertificateAuth", + "IAMAuth", + "JWTAuth", + "KerberosAuth", + "NoAuth", + "OAuth2Auth", + "PasswordAuth", + "ServiceAccountAuth", + "TokenAuth", + "WindowsAuth", +] diff --git a/src/mountainash_settings/auth/azure.py b/src/mountainash_settings/auth/azure.py new file mode 100644 index 0000000..a9f7539 --- /dev/null +++ b/src/mountainash_settings/auth/azure.py @@ -0,0 +1,30 @@ +"""Microsoft-centric authentication: Windows integrated + Azure AD.""" + +from __future__ import annotations + +import typing as t + +from pydantic import SecretStr + +from .base import AuthSpec + +__all__ = ["WindowsAuth", "AzureADAuth"] + + +class WindowsAuth(AuthSpec): + """Integrated Windows authentication (MSSQL).""" + + kind: t.Literal["windows"] = "windows" + username: str | None = None + domain: str | None = None + + +class AzureADAuth(AuthSpec): + """Azure Active Directory authentication (MSSQL).""" + + kind: t.Literal["azure_ad"] = "azure_ad" + tenant_id: str | None = None + client_id: str | None = None + client_secret: SecretStr | None = None + managed_identity: bool = False + msi_endpoint: str | None = None diff --git a/src/mountainash_settings/auth/certificate.py b/src/mountainash_settings/auth/certificate.py new file mode 100644 index 0000000..5c9ff3f --- /dev/null +++ b/src/mountainash_settings/auth/certificate.py @@ -0,0 +1,21 @@ +"""Private-key / certificate authentication (Snowflake JWT).""" + +from __future__ import annotations + +import typing as t +from pathlib import Path + +from pydantic import SecretStr + +from .base import AuthSpec + +__all__ = ["CertificateAuth"] + + +class CertificateAuth(AuthSpec): + """Private-key signed JWT authentication (Snowflake).""" + + kind: t.Literal["certificate"] = "certificate" + private_key: SecretStr | None = None + private_key_path: Path | None = None + passphrase: SecretStr | None = None diff --git a/src/mountainash_settings/auth/iam.py b/src/mountainash_settings/auth/iam.py new file mode 100644 index 0000000..1a103e3 --- /dev/null +++ b/src/mountainash_settings/auth/iam.py @@ -0,0 +1,22 @@ +"""AWS IAM authentication.""" + +from __future__ import annotations + +import typing as t + +from pydantic import SecretStr + +from .base import AuthSpec + +__all__ = ["IAMAuth"] + + +class IAMAuth(AuthSpec): + """AWS IAM credentials (Redshift, S3-backed catalogs).""" + + kind: t.Literal["iam"] = "iam" + role_arn: str | None = None + access_key_id: str | None = None + secret_access_key: SecretStr | None = None + session_token: SecretStr | None = None + profile_name: str | None = None diff --git a/src/mountainash_settings/auth/kerberos.py b/src/mountainash_settings/auth/kerberos.py new file mode 100644 index 0000000..d090cbe --- /dev/null +++ b/src/mountainash_settings/auth/kerberos.py @@ -0,0 +1,19 @@ +"""Kerberos / GSSAPI authentication.""" + +from __future__ import annotations + +import typing as t +from pathlib import Path + +from .base import AuthSpec + +__all__ = ["KerberosAuth"] + + +class KerberosAuth(AuthSpec): + """Kerberos authentication (Trino, PostgreSQL via GSS).""" + + kind: t.Literal["kerberos"] = "kerberos" + service_name: str = "postgres" + principal: str | None = None + keytab: Path | None = None diff --git a/src/mountainash_settings/auth/none.py b/src/mountainash_settings/auth/none.py new file mode 100644 index 0000000..dc6efcf --- /dev/null +++ b/src/mountainash_settings/auth/none.py @@ -0,0 +1,15 @@ +"""The 'no authentication' variant.""" + +from __future__ import annotations + +import typing as t + +from .base import AuthSpec + +__all__ = ["NoAuth"] + + +class NoAuth(AuthSpec): + """No authentication required (SQLite, DuckDB, PySpark).""" + + kind: t.Literal["none"] = "none" diff --git a/src/mountainash_settings/auth/oauth2.py b/src/mountainash_settings/auth/oauth2.py new file mode 100644 index 0000000..1e82936 --- /dev/null +++ b/src/mountainash_settings/auth/oauth2.py @@ -0,0 +1,23 @@ +"""OAuth2 client-credentials / token authentication.""" + +from __future__ import annotations + +import typing as t + +from pydantic import SecretStr + +from .base import AuthSpec + +__all__ = ["OAuth2Auth"] + + +class OAuth2Auth(AuthSpec): + """OAuth2 credential set (Snowflake, Trino, PyIceberg REST).""" + + kind: t.Literal["oauth2"] = "oauth2" + client_id: str | None = None + client_secret: SecretStr | None = None + token: SecretStr | None = None + refresh_token: SecretStr | None = None + server_uri: str | None = None + scope: str | None = None diff --git a/src/mountainash_settings/auth/password.py b/src/mountainash_settings/auth/password.py new file mode 100644 index 0000000..c885f55 --- /dev/null +++ b/src/mountainash_settings/auth/password.py @@ -0,0 +1,19 @@ +"""Classic username + password authentication.""" + +from __future__ import annotations + +import typing as t + +from pydantic import SecretStr + +from .base import AuthSpec + +__all__ = ["PasswordAuth"] + + +class PasswordAuth(AuthSpec): + """Username + password authentication.""" + + kind: t.Literal["password"] = "password" + username: str + password: SecretStr diff --git a/src/mountainash_settings/auth/service_account.py b/src/mountainash_settings/auth/service_account.py new file mode 100644 index 0000000..5c74a91 --- /dev/null +++ b/src/mountainash_settings/auth/service_account.py @@ -0,0 +1,18 @@ +"""Google-style service-account authentication.""" + +from __future__ import annotations + +import typing as t +from pathlib import Path + +from .base import AuthSpec + +__all__ = ["ServiceAccountAuth"] + + +class ServiceAccountAuth(AuthSpec): + """Google Cloud service-account key (JSON dict or file path).""" + + kind: t.Literal["service_account"] = "service_account" + info: dict[str, t.Any] | None = None + file: Path | None = None diff --git a/src/mountainash_settings/auth/token.py b/src/mountainash_settings/auth/token.py new file mode 100644 index 0000000..a5ea882 --- /dev/null +++ b/src/mountainash_settings/auth/token.py @@ -0,0 +1,25 @@ +"""Bearer-token and JWT authentication.""" + +from __future__ import annotations + +import typing as t + +from pydantic import SecretStr + +from .base import AuthSpec + +__all__ = ["TokenAuth", "JWTAuth"] + + +class TokenAuth(AuthSpec): + """Opaque bearer token (e.g. MotherDuck, PyIceberg REST).""" + + kind: t.Literal["token"] = "token" + token: SecretStr + + +class JWTAuth(AuthSpec): + """JSON Web Token authentication (e.g. Trino).""" + + kind: t.Literal["jwt"] = "jwt" + token: SecretStr diff --git a/tests/unit/auth/test_subclasses.py b/tests/unit/auth/test_subclasses.py new file mode 100644 index 0000000..fcd5ae6 --- /dev/null +++ b/tests/unit/auth/test_subclasses.py @@ -0,0 +1,83 @@ +"""Unit tests for AuthSpec discriminated-union members.""" + +import pytest +from pydantic import SecretStr, ValidationError + +from mountainash_settings.auth import ( + AzureADAuth, + CertificateAuth, + IAMAuth, + JWTAuth, + KerberosAuth, + NoAuth, + OAuth2Auth, + PasswordAuth, + ServiceAccountAuth, + TokenAuth, + WindowsAuth, +) + + +@pytest.mark.unit +class TestAuthDiscriminator: + @pytest.mark.parametrize( + "cls, kind", + [ + (NoAuth, "none"), + (PasswordAuth, "password"), + (TokenAuth, "token"), + (JWTAuth, "jwt"), + (OAuth2Auth, "oauth2"), + (ServiceAccountAuth, "service_account"), + (IAMAuth, "iam"), + (WindowsAuth, "windows"), + (AzureADAuth, "azure_ad"), + (KerberosAuth, "kerberos"), + (CertificateAuth, "certificate"), + ], + ) + def test_every_auth_has_discriminator_kind(self, cls, kind): + if cls is PasswordAuth: + instance = cls(username="u", password=SecretStr("p")) + elif cls in (TokenAuth, JWTAuth): + instance = cls(token=SecretStr("t")) + else: + instance = cls() + assert instance.kind == kind + + def test_password_auth_requires_username_and_password(self): + with pytest.raises(ValidationError): + PasswordAuth() # type: ignore[call-arg] + + def test_password_auth_wraps_password_as_secretstr(self): + auth = PasswordAuth(username="alice", password="hunter2") + assert isinstance(auth.password, SecretStr) + assert auth.password.get_secret_value() == "hunter2" + + def test_noauth_has_no_fields(self): + auth = NoAuth() + assert auth.kind == "none" + + def test_auth_is_frozen(self): + """Mutation of an AuthSpec instance must raise.""" + auth = NoAuth() + with pytest.raises(ValidationError): + auth.kind = "password" # type: ignore[misc] + + def test_auth_rejects_unknown_fields(self): + """Unknown kwargs must raise because model_config.extra == 'forbid'.""" + with pytest.raises(ValidationError): + NoAuth(bogus="x") # type: ignore[call-arg] + + +@pytest.mark.unit +class TestOAuth2Auth: + def test_all_fields_optional(self): + auth = OAuth2Auth() + assert auth.client_id is None + assert auth.client_secret is None + assert auth.token is None + + def test_token_is_secret(self): + auth = OAuth2Auth(token="t") + assert isinstance(auth.token, SecretStr) From 7c090667e2908373f57e02c9e18f38b01d276657 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Fri, 17 Apr 2026 11:04:25 +1000 Subject: [PATCH 08/13] feat(auth): add AUTH_TO_DRIVER_KWARGS default dispatch map Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_settings/auth/__init__.py | 3 + src/mountainash_settings/auth/dispatch.py | 80 +++++++++++++++++++++ tests/unit/auth/test_dispatch.py | 88 +++++++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 src/mountainash_settings/auth/dispatch.py create mode 100644 tests/unit/auth/test_dispatch.py diff --git a/src/mountainash_settings/auth/__init__.py b/src/mountainash_settings/auth/__init__.py index f123ba2..abeabff 100644 --- a/src/mountainash_settings/auth/__init__.py +++ b/src/mountainash_settings/auth/__init__.py @@ -11,9 +11,11 @@ from .oauth2 import OAuth2Auth from .password import PasswordAuth from .service_account import ServiceAccountAuth +from .dispatch import AUTH_TO_DRIVER_KWARGS, auth_to_driver_kwargs from .token import JWTAuth, TokenAuth __all__ = [ + "AUTH_TO_DRIVER_KWARGS", "AuthSpec", "AzureADAuth", "CertificateAuth", @@ -26,4 +28,5 @@ "ServiceAccountAuth", "TokenAuth", "WindowsAuth", + "auth_to_driver_kwargs", ] diff --git a/src/mountainash_settings/auth/dispatch.py b/src/mountainash_settings/auth/dispatch.py new file mode 100644 index 0000000..acdb604 --- /dev/null +++ b/src/mountainash_settings/auth/dispatch.py @@ -0,0 +1,80 @@ +"""Default mapping from AuthSpec instances to driver kwargs. + +Backend adapters can override individual auth types by consulting this map or +by writing bespoke match statements. The defaults cover the common case. +""" + +from __future__ import annotations + +import typing as t + +from .base import AuthSpec +from .iam import IAMAuth +from .none import NoAuth +from .oauth2 import OAuth2Auth +from .password import PasswordAuth +from .token import JWTAuth, TokenAuth + +__all__ = ["AUTH_TO_DRIVER_KWARGS", "auth_to_driver_kwargs"] + + +def _noauth(_auth: NoAuth) -> dict[str, t.Any]: + return {} + + +def _password(auth: PasswordAuth) -> dict[str, t.Any]: + return { + "user": auth.username, + "password": auth.password.get_secret_value(), + } + + +def _token(auth: TokenAuth) -> dict[str, t.Any]: + return {"token": auth.token.get_secret_value()} + + +def _jwt(auth: JWTAuth) -> dict[str, t.Any]: + return {"token": auth.token.get_secret_value()} + + +def _oauth2(auth: OAuth2Auth) -> dict[str, t.Any]: + if auth.token is not None: + return {"token": auth.token.get_secret_value()} + if auth.client_id is not None and auth.client_secret is not None: + return {"credential": f"{auth.client_id}:{auth.client_secret.get_secret_value()}"} + return {} + + +def _iam(auth: IAMAuth) -> dict[str, t.Any]: + """Empty dict means 'use ambient AWS credentials' (env vars, instance profile, SSO).""" + out: dict[str, t.Any] = {} + if auth.role_arn is not None: + out["iam_role_arn"] = auth.role_arn + if auth.access_key_id is not None: + out["aws_access_key_id"] = auth.access_key_id + if auth.secret_access_key is not None: + out["aws_secret_access_key"] = auth.secret_access_key.get_secret_value() + if auth.session_token is not None: + out["aws_session_token"] = auth.session_token.get_secret_value() + return out + + +AUTH_TO_DRIVER_KWARGS: dict[type[AuthSpec], t.Callable[[t.Any], dict[str, t.Any]]] = { + NoAuth: _noauth, + PasswordAuth: _password, + TokenAuth: _token, + JWTAuth: _jwt, + OAuth2Auth: _oauth2, + IAMAuth: _iam, + # WindowsAuth, AzureADAuth, KerberosAuth, ServiceAccountAuth, CertificateAuth: + # no sensible default — their respective backend adapters handle mapping. +} + + +def auth_to_driver_kwargs(auth: AuthSpec) -> dict[str, t.Any]: + """Look up the default mapper for ``auth`` and produce driver kwargs. + + Raises: + KeyError: if no mapper is registered for ``type(auth)``. + """ + return AUTH_TO_DRIVER_KWARGS[type(auth)](auth) diff --git a/tests/unit/auth/test_dispatch.py b/tests/unit/auth/test_dispatch.py new file mode 100644 index 0000000..bbaec4d --- /dev/null +++ b/tests/unit/auth/test_dispatch.py @@ -0,0 +1,88 @@ +"""Default AUTH_TO_DRIVER_KWARGS coverage tests.""" + +import pytest +from pydantic import SecretStr + +from mountainash_settings.auth import ( + AuthSpec, + IAMAuth, + JWTAuth, + NoAuth, + OAuth2Auth, + PasswordAuth, + TokenAuth, +) +from mountainash_settings.auth.dispatch import auth_to_driver_kwargs + + +@pytest.mark.unit +class TestAuthToDriverKwargs: + def test_noauth_returns_empty(self): + assert auth_to_driver_kwargs(NoAuth()) == {} + + def test_password_unwraps_secret(self): + auth = PasswordAuth(username="alice", password=SecretStr("hunter2")) + assert auth_to_driver_kwargs(auth) == { + "user": "alice", + "password": "hunter2", + } + + def test_token_unwraps_secret(self): + auth = TokenAuth(token=SecretStr("t")) + assert auth_to_driver_kwargs(auth) == {"token": "t"} + + def test_jwt_unwraps_secret(self): + auth = JWTAuth(token=SecretStr("j")) + assert auth_to_driver_kwargs(auth) == {"token": "j"} + + def test_oauth2_with_token(self): + auth = OAuth2Auth(token=SecretStr("bearer")) + assert auth_to_driver_kwargs(auth) == {"token": "bearer"} + + def test_oauth2_with_client_credentials(self): + auth = OAuth2Auth( + client_id="cid", client_secret=SecretStr("csec") + ) + assert auth_to_driver_kwargs(auth) == {"credential": "cid:csec"} + + def test_oauth2_token_wins_over_client_credentials(self): + """Policy: if both token and client_credentials are set, token wins.""" + auth = OAuth2Auth( + token=SecretStr("t"), + client_id="c", + client_secret=SecretStr("s"), + ) + assert auth_to_driver_kwargs(auth) == {"token": "t"} + + def test_oauth2_empty_returns_empty(self): + """OAuth2 with neither token nor client-credentials yields no kwargs.""" + assert auth_to_driver_kwargs(OAuth2Auth()) == {} + + def test_iam_with_keys(self): + auth = IAMAuth( + access_key_id="AKIA...", + secret_access_key=SecretStr("sk"), + session_token=SecretStr("st"), + ) + assert auth_to_driver_kwargs(auth) == { + "aws_access_key_id": "AKIA...", + "aws_secret_access_key": "sk", + "aws_session_token": "st", + } + + def test_iam_with_role_arn(self): + auth = IAMAuth(role_arn="arn:aws:iam::123:role/x") + assert auth_to_driver_kwargs(auth) == { + "iam_role_arn": "arn:aws:iam::123:role/x" + } + + def test_iam_empty_returns_empty(self): + """IAM with no explicit fields falls through to ambient credentials.""" + assert auth_to_driver_kwargs(IAMAuth()) == {} + + def test_unknown_auth_type_raises(self): + class WeirdAuth(AuthSpec): + kind: str = "weird" # type: ignore[assignment] + + with pytest.raises(KeyError): + auth_to_driver_kwargs(WeirdAuth()) From c9a0152577810a3383632676ba6e03b7dd7bbf50 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Fri, 17 Apr 2026 11:06:00 +1000 Subject: [PATCH 09/13] feat(profiles): add ProfileDescriptor + ParameterSpec with template support Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_settings/profiles/__init__.py | 13 +++ .../profiles/descriptor.py | 97 +++++++++++++++++++ tests/unit/profiles/__init__.py | 0 tests/unit/profiles/test_descriptor.py | 70 +++++++++++++ 4 files changed, 180 insertions(+) create mode 100644 src/mountainash_settings/profiles/__init__.py create mode 100644 src/mountainash_settings/profiles/descriptor.py create mode 100644 tests/unit/profiles/__init__.py create mode 100644 tests/unit/profiles/test_descriptor.py diff --git a/src/mountainash_settings/profiles/__init__.py b/src/mountainash_settings/profiles/__init__.py new file mode 100644 index 0000000..e44e687 --- /dev/null +++ b/src/mountainash_settings/profiles/__init__.py @@ -0,0 +1,13 @@ +# src/mountainash_settings/profiles/__init__.py +"""Declarative settings profiles — descriptor + registry + generic base. + +Lifted and generalized from mountainash-data's 2026-04-15 settings-registry +refactor. See design spec: +``docs/superpowers/specs/2026-04-16-profiles-promotion-design.md``. +""" + +from __future__ import annotations + +from .descriptor import MISSING, ParameterSpec, ProfileDescriptor + +__all__ = ["MISSING", "ParameterSpec", "ProfileDescriptor"] diff --git a/src/mountainash_settings/profiles/descriptor.py b/src/mountainash_settings/profiles/descriptor.py new file mode 100644 index 0000000..95788c1 --- /dev/null +++ b/src/mountainash_settings/profiles/descriptor.py @@ -0,0 +1,97 @@ +# src/mountainash_settings/profiles/descriptor.py +"""Declarative descriptors for settings profiles. + +A :class:`ProfileDescriptor` captures everything the generic +:class:`DescriptorProfile` base needs to install pydantic fields for a given +configuration. A :class:`ParameterSpec` describes one field within a +descriptor. + +Extends the ``BackendDescriptor`` from mountainash-data's 2026-04-15 refactor +by renaming for generality (not all profiles are for "backends") and adding +``ParameterSpec.template`` for declarative template-driven derived fields. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +__all__ = ["MISSING", "ParameterSpec", "ProfileDescriptor"] + + +class _Missing: + """Sentinel indicating a required (no-default) field. + + Pydantic ``Field(...)`` is emitted when a :class:`ParameterSpec` default + is this sentinel; ``Field(default=...)`` otherwise. + """ + + _instance: "t.ClassVar[_Missing | None]" = None + + def __new__(cls) -> "_Missing": + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __repr__(self) -> str: + return "MISSING" + + def __bool__(self) -> bool: + return False + + +MISSING: _Missing = _Missing() + + +@dataclass(frozen=True, kw_only=True) +class ParameterSpec: + """One settings field on a profile. + + Attributes: + name: Settings-facing uppercase name (e.g. ``"SSL_CERT"``). + type: Pydantic-compatible annotation (``str``, ``int | None``, enum, …). + tier: ``"core"`` or ``"advanced"`` — audit-style severity tier. + default: Default value; :data:`MISSING` means the field is required. + description: Optional docstring for generated schemas / help output. + driver_key: Output-kwarg name for 1:1 mappings (e.g. ``"sslcert"``). + ``None`` means a domain adapter handles emission. + secret: If ``True``, wrap ``type`` as :class:`pydantic.SecretStr` and + auto-unwrap via ``.get_secret_value()`` at the kwargs boundary. + transform: Optional callable applied when emitting kwargs. + validator: Optional pydantic-compatible field-level validator. + template: Optional template string; when set, + :class:`DescriptorProfile` auto-wires ``init_setting_from_template`` + in ``post_init`` to populate this field. + """ + + name: str + type: t.Any + tier: t.Literal["core", "advanced"] + default: t.Any = MISSING + description: str = "" + driver_key: str | None = None + secret: bool = False + transform: t.Callable[[t.Any], t.Any] | None = None + validator: t.Callable[[t.Any], t.Any] | None = None + template: str | None = None + + +@dataclass(frozen=True, kw_only=True) +class ProfileDescriptor: + """Immutable description of a single settings profile. + + Attributes: + name: Short name (conventionally lowercase, e.g. ``"postgresql"``). + provider_type: Canonical provider identifier (domain-specific enum). + parameters: Ordered list of :class:`ParameterSpec`. + auth_modes: List of :class:`AuthSpec` subclasses this profile accepts. + metadata: Bag of domain-specific metadata (e.g. port, URL scheme, + dialect name). Domains wanting strong typing may subclass + ``ProfileDescriptor`` and add typed fields instead. + """ + + name: str + provider_type: t.Any + parameters: list[ParameterSpec] + auth_modes: list[type] # list[type[AuthSpec]] — forward-refd to avoid cycle + metadata: dict[str, t.Any] = field(default_factory=dict) diff --git a/tests/unit/profiles/__init__.py b/tests/unit/profiles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/profiles/test_descriptor.py b/tests/unit/profiles/test_descriptor.py new file mode 100644 index 0000000..52ced38 --- /dev/null +++ b/tests/unit/profiles/test_descriptor.py @@ -0,0 +1,70 @@ +# tests/unit/profiles/test_descriptor.py +"""Unit tests for ProfileDescriptor, ParameterSpec, and MISSING.""" + +import pytest + +from mountainash_settings.auth import NoAuth +from mountainash_settings.profiles.descriptor import ( + MISSING, + ParameterSpec, + ProfileDescriptor, +) + + +@pytest.mark.unit +class TestMissing: + def test_missing_is_singleton(self): + from mountainash_settings.profiles.descriptor import _Missing + assert _Missing() is MISSING + + def test_missing_is_falsy(self): + assert not MISSING + + def test_missing_repr(self): + assert repr(MISSING) == "MISSING" + + +@pytest.mark.unit +class TestParameterSpec: + def test_minimal(self): + p = ParameterSpec(name="X", type=str, tier="core") + assert p.name == "X" + assert p.default is MISSING + assert p.template is None + + def test_frozen(self): + p = ParameterSpec(name="X", type=str, tier="core") + with pytest.raises(Exception): + p.name = "Y" # type: ignore + + def test_template_field(self): + p = ParameterSpec(name="URL", type=str, tier="core", + template="https://{HOST}/api") + assert p.template == "https://{HOST}/api" + + +@pytest.mark.unit +class TestProfileDescriptor: + def test_minimal(self): + d = ProfileDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], + ) + assert d.name == "x" + assert d.metadata == {} + + def test_metadata(self): + d = ProfileDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], + metadata={"port": 5432, "scheme": "x://"}, + ) + assert d.metadata["port"] == 5432 + + def test_frozen(self): + d = ProfileDescriptor( + name="x", provider_type="x", + parameters=[], auth_modes=[NoAuth], + ) + with pytest.raises(Exception): + d.name = "y" # type: ignore From 480e84d286ee7b12aaecd7b6f9c1be18074482a8 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Fri, 17 Apr 2026 11:07:19 +1000 Subject: [PATCH 10/13] feat(profiles): add per-domain Registry class with bound decorator Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_settings/profiles/__init__.py | 10 +- src/mountainash_settings/profiles/registry.py | 137 ++++++++++++++++++ tests/unit/profiles/test_registry.py | 103 +++++++++++++ 3 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 src/mountainash_settings/profiles/registry.py create mode 100644 tests/unit/profiles/test_registry.py diff --git a/src/mountainash_settings/profiles/__init__.py b/src/mountainash_settings/profiles/__init__.py index e44e687..c2a6345 100644 --- a/src/mountainash_settings/profiles/__init__.py +++ b/src/mountainash_settings/profiles/__init__.py @@ -1,13 +1,9 @@ # src/mountainash_settings/profiles/__init__.py -"""Declarative settings profiles — descriptor + registry + generic base. - -Lifted and generalized from mountainash-data's 2026-04-15 settings-registry -refactor. See design spec: -``docs/superpowers/specs/2026-04-16-profiles-promotion-design.md``. -""" +"""Declarative settings profiles — descriptor + registry + generic base.""" from __future__ import annotations from .descriptor import MISSING, ParameterSpec, ProfileDescriptor +from .registry import Registry -__all__ = ["MISSING", "ParameterSpec", "ProfileDescriptor"] +__all__ = ["MISSING", "ParameterSpec", "ProfileDescriptor", "Registry"] diff --git a/src/mountainash_settings/profiles/registry.py b/src/mountainash_settings/profiles/registry.py new file mode 100644 index 0000000..675e942 --- /dev/null +++ b/src/mountainash_settings/profiles/registry.py @@ -0,0 +1,137 @@ +# src/mountainash_settings/profiles/registry.py +"""Per-domain registry of profile descriptors and settings classes. + +Each consumer domain instantiates one :class:`Registry` with a name (used in +error messages and test IDs). Example:: + + DATABASES_REGISTRY = Registry("databases") + register = DATABASES_REGISTRY.decorator() + + @register(POSTGRESQL_DESCRIPTOR) + class PostgreSQLAuthSettings(ConnectionProfile): + __descriptor__ = POSTGRESQL_DESCRIPTOR +""" + +from __future__ import annotations + +import typing as t + +from .descriptor import ProfileDescriptor + +if t.TYPE_CHECKING: + from .profile import DescriptorProfile + +__all__ = ["Registry"] + + +T = t.TypeVar("T", bound="DescriptorProfile") + + +class Registry: + """Mutable, name-keyed store of descriptors + their settings classes.""" + + def __init__(self, name: str) -> None: + self.name = name + self._descriptors: dict[str, ProfileDescriptor] = {} + self._classes: dict[str, type["DescriptorProfile"]] = {} + + def __len__(self) -> int: + return len(self._descriptors) + + def __contains__(self, name: str) -> bool: + return name in self._descriptors + + @property + def descriptors(self) -> dict[str, ProfileDescriptor]: + """Read-only view of the descriptor dict.""" + return dict(self._descriptors) + + def register( + self, + descriptor: ProfileDescriptor, + cls: type["DescriptorProfile"], + ) -> None: + """Register ``cls`` under ``descriptor.name``. + + Raises: + ValueError: if ``descriptor.name`` is already registered. + """ + if descriptor.name in self._descriptors: + existing = self._classes.get(descriptor.name) + where = ( + f"{existing.__module__}.{existing.__qualname__}" + if existing is not None + else "" + ) + raise ValueError( + f"Profile {descriptor.name!r} is already registered " + f"in {self.name} registry by {where}" + ) + self._descriptors[descriptor.name] = descriptor + self._classes[descriptor.name] = cls + cls.__descriptor__ = descriptor # belt-and-braces + + def decorator( + self, + ) -> t.Callable[[ProfileDescriptor], t.Callable[[type[T]], type[T]]]: + """Return a bound ``@register(descriptor)`` class decorator.""" + + def _factory(descriptor: ProfileDescriptor) -> t.Callable[[type[T]], type[T]]: + def _wrap(cls: type[T]) -> type[T]: + self.register(descriptor, cls) + return cls + return _wrap + + return _factory + + def get_descriptor(self, name: str) -> ProfileDescriptor: + """Return the descriptor for ``name``. + + Raises: + KeyError: with a hint listing known names. + """ + try: + return self._descriptors[name] + except KeyError: + known = ", ".join(sorted(self._descriptors)) or "" + raise KeyError( + f"No profile registered under {name!r} in {self.name} " + f"registry. Known: {known}" + ) from None + + def get_settings_class(self, name: str) -> type["DescriptorProfile"]: + """Return the settings class for ``name``. + + Raises: + KeyError: with a hint listing known names. + """ + try: + return self._classes[name] + except KeyError: + known = ", ".join(sorted(self._classes)) or "" + raise KeyError( + f"No settings class registered under {name!r} in " + f"{self.name} registry. Known: {known}" + ) from None + + # --- Test seams --------------------------------------------------------- + + def _snapshot_for_tests( + self, + ) -> tuple[ + dict[str, ProfileDescriptor], + dict[str, type["DescriptorProfile"]], + ]: + """Snapshot for later :meth:`_reset_for_tests` restore.""" + return self._descriptors.copy(), self._classes.copy() + + def _reset_for_tests( + self, + descriptors_snapshot: dict[str, ProfileDescriptor], + classes_snapshot: dict[str, type["DescriptorProfile"]], + ) -> None: + """Restore descriptors + classes dicts to snapshots (test-only).""" + self._descriptors.clear() + self._descriptors.update(descriptors_snapshot) + self._classes.clear() + self._classes.update(classes_snapshot) diff --git a/tests/unit/profiles/test_registry.py b/tests/unit/profiles/test_registry.py new file mode 100644 index 0000000..7b71ae5 --- /dev/null +++ b/tests/unit/profiles/test_registry.py @@ -0,0 +1,103 @@ +# tests/unit/profiles/test_registry.py +"""Unit tests for the Registry class.""" + +import pytest + +from mountainash_settings.auth import NoAuth +from mountainash_settings.profiles.descriptor import ProfileDescriptor +from mountainash_settings.profiles.registry import Registry + + +def _make_desc(name: str) -> ProfileDescriptor: + return ProfileDescriptor( + name=name, provider_type=name, parameters=[], auth_modes=[NoAuth], + ) + + +@pytest.mark.unit +class TestRegistry: + def test_register_inserts(self): + reg = Registry("test") + desc = _make_desc("foo") + + register = reg.decorator() + + @register(desc) + class Foo: + pass + + assert "foo" in reg + assert reg.get_descriptor("foo") is desc + assert reg.get_settings_class("foo") is Foo + + def test_duplicate_raises(self): + reg = Registry("test") + desc1 = _make_desc("dup") + desc2 = _make_desc("dup") + register = reg.decorator() + + @register(desc1) + class First: + pass + + with pytest.raises(ValueError, match="already registered"): + @register(desc2) + class Second: + pass + + def test_get_descriptor_unknown_hints(self): + reg = Registry("storage") + desc = _make_desc("s3") + register = reg.decorator() + + @register(desc) + class S3: + pass + + with pytest.raises(KeyError, match="Known: s3"): + reg.get_descriptor("not_a_real_one") + + def test_get_settings_class_unknown_hints(self): + reg = Registry("storage") + with pytest.raises(KeyError, match="Known: "): + reg.get_settings_class("nope") + + def test_duplicate_does_not_pollute_classes(self): + reg = Registry("test") + desc1 = _make_desc("inv") + desc2 = _make_desc("inv") + register = reg.decorator() + + @register(desc1) + class First: + pass + + with pytest.raises(ValueError): + @register(desc2) + class Second: + pass + + assert reg.get_settings_class("inv") is First + assert reg.get_descriptor("inv") is desc1 + + def test_snapshot_and_reset(self): + reg = Registry("t") + desc = _make_desc("x") + reg.decorator()(desc)(type("X", (), {})) + snap = reg._snapshot_for_tests() + + reg.decorator()(_make_desc("y"))(type("Y", (), {})) + assert "y" in reg + + reg._reset_for_tests(*snap) + assert "y" not in reg + assert "x" in reg + + def test_descriptors_view_is_copy(self): + reg = Registry("t") + desc = _make_desc("a") + reg.decorator()(desc)(type("A", (), {})) + + view = reg.descriptors + view["fake"] = desc # mutating the view does not affect the registry + assert "fake" not in reg From 38b9e17f20c77851ec686df972e8c06aad64afd6 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Fri, 17 Apr 2026 11:09:49 +1000 Subject: [PATCH 11/13] feat(profiles): add DescriptorProfile base (Pattern A mechanism) Lifts ConnectionProfile from mountainash-data, renames to DescriptorProfile, drops domain-specific to_driver_kwargs/to_connection_string, renames helpers to _default_kwargs/_auth_kwargs, and wires ParameterSpec.template via post_init (Pattern B). All 13 new tests pass alongside prior 16 (29 total). Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_settings/profiles/__init__.py | 9 +- src/mountainash_settings/profiles/profile.py | 179 ++++++++++++++++++ tests/unit/profiles/test_profile.py | 165 ++++++++++++++++ 3 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 src/mountainash_settings/profiles/profile.py create mode 100644 tests/unit/profiles/test_profile.py diff --git a/src/mountainash_settings/profiles/__init__.py b/src/mountainash_settings/profiles/__init__.py index c2a6345..d9335b3 100644 --- a/src/mountainash_settings/profiles/__init__.py +++ b/src/mountainash_settings/profiles/__init__.py @@ -4,6 +4,13 @@ from __future__ import annotations from .descriptor import MISSING, ParameterSpec, ProfileDescriptor +from .profile import DescriptorProfile from .registry import Registry -__all__ = ["MISSING", "ParameterSpec", "ProfileDescriptor", "Registry"] +__all__ = [ + "MISSING", + "DescriptorProfile", + "ParameterSpec", + "ProfileDescriptor", + "Registry", +] diff --git a/src/mountainash_settings/profiles/profile.py b/src/mountainash_settings/profiles/profile.py new file mode 100644 index 0000000..84fcd8d --- /dev/null +++ b/src/mountainash_settings/profiles/profile.py @@ -0,0 +1,179 @@ +# src/mountainash_settings/profiles/profile.py +"""Generic DescriptorProfile base for declarative settings profiles. + +A subclass declares ``__descriptor__`` (a :class:`ProfileDescriptor`); this +base uses pydantic v2's ``__pydantic_init_subclass__`` hook to materialize the +descriptor into pydantic fields and compose the :class:`AuthSpec` union into +the ``auth`` field. Consumers add their own domain-specific output methods +(e.g. ``to_driver_kwargs()``) in their own subclasses. +""" + +from __future__ import annotations + +import typing as t + +from pydantic import AfterValidator, SecretStr +from pydantic.fields import FieldInfo + +from mountainash_settings import MountainAshBaseSettings +from mountainash_settings.auth import auth_to_driver_kwargs + +from .descriptor import MISSING, ProfileDescriptor + +__all__ = ["DescriptorProfile"] + + +class DescriptorProfile(MountainAshBaseSettings): + """Declarative settings base — subclasses set ``__descriptor__`` only. + + Public contract: + - :attr:`backend` / :attr:`profile_name` — descriptor name. + - :attr:`provider_type` — descriptor provider_type. + - :meth:`_default_kwargs` — 1:1 ``driver_key`` mappings from the descriptor. + - :meth:`_auth_kwargs` — default auth dispatch (consumers may override). + - ``__adapter__`` — if set, adapter owns the output pipeline. + """ + + __descriptor__: t.ClassVar[ProfileDescriptor] + __adapter__: t.ClassVar[ + t.Callable[["DescriptorProfile"], dict[str, t.Any]] | None + ] = None + + @classmethod + def __pydantic_init_subclass__(cls, **kwargs: t.Any) -> None: + """Install fields described by ``__descriptor__`` on the subclass.""" + super().__pydantic_init_subclass__(**kwargs) + desc = cls.__dict__.get("__descriptor__") + if desc is None: + return # intermediate subclass without its own descriptor + + new_fields: dict[str, tuple[t.Any, FieldInfo]] = {} + + # 1. Descriptor parameters → pydantic fields + for spec in desc.parameters: + ptype: t.Any = SecretStr if spec.secret else spec.type + if spec.validator is not None: + ptype = t.Annotated[ptype, AfterValidator(spec.validator)] + if spec.default is MISSING: + info = FieldInfo( + annotation=ptype, + default=..., + description=spec.description, + ) + else: + info = FieldInfo( + annotation=ptype, + default=spec.default, + description=spec.description, + ) + new_fields[spec.name] = (ptype, info) + + # 2. auth field as discriminated union of descriptor.auth_modes + if desc.auth_modes: + auth_union: t.Any + if len(desc.auth_modes) == 1: + auth_union = desc.auth_modes[0] + auth_info = FieldInfo(annotation=auth_union, default=...) + else: + auth_union = t.Union[tuple(desc.auth_modes)] # type: ignore[valid-type] + auth_info = FieldInfo( + annotation=auth_union, + default=..., + discriminator="kind", + ) + new_fields["auth"] = (auth_union, auth_info) + + for name, (annotation, info) in new_fields.items(): + cls.model_fields[name] = info + cls.__annotations__[name] = annotation + + cls.model_rebuild(force=True) + + # --- Public properties --------------------------------------------------- + + @property + def profile_name(self) -> str: + return self.__descriptor__.name + + @property + def backend(self) -> str: + """Alias for ``profile_name`` — preserves naming from mountainash-data.""" + return self.__descriptor__.name + + @property + def provider_type(self) -> t.Any: + return self.__descriptor__.provider_type + + # --- Template wiring ----------------------------------------------------- + + def post_init( + self, + template_settings_parameters: t.Any = None, + reinitialise: bool = False, + ) -> None: + """Resolve any ``ParameterSpec.template`` fields. + + Runs ``init_setting_from_template`` for each parameter with a + template string. Respects explicit user-provided values — templates + only populate fields that match their declared default. + """ + super().post_init( + template_settings_parameters=template_settings_parameters, + reinitialise=reinitialise, + ) + desc = type(self).__dict__.get("__descriptor__") + if desc is None: + for base in type(self).__mro__[1:]: + cand = base.__dict__.get("__descriptor__") + if cand is not None: + desc = cand + break + if desc is None: + return + for spec in desc.parameters: + if spec.template is None: + continue + current = getattr(self, spec.name, None) + # Only apply template when value matches the declared default + # (caller-provided explicit values win). + spec_default = spec.default if spec.default is not MISSING else None + if current not in (spec_default, None, ""): + continue + new_val = self.init_setting_from_template( + template_str=spec.template, + current_value=None, # force template evaluation + reinitialise=reinitialise, + ) + object.__setattr__(self, spec.name, new_val) + + # --- Kwargs helpers ------------------------------------------------------ + + def _default_kwargs(self) -> dict[str, t.Any]: + """Emit 1:1 ``driver_key`` mappings from the descriptor. + + - Skips ``None`` values. + - Unwraps :class:`SecretStr` via ``.get_secret_value()``. + - Applies ``ParameterSpec.transform`` if set. + """ + out: dict[str, t.Any] = {} + for spec in self.__descriptor__.parameters: + if spec.driver_key is None: + continue + val = getattr(self, spec.name, None) + if val is None: + continue + # Accommodates both pydantic-coerced (SecretStr) and + # setattr-bypass (raw str) construction paths. + if isinstance(val, SecretStr): + val = val.get_secret_value() + if spec.transform is not None: + val = spec.transform(val) + out[spec.driver_key] = val + return out + + def _auth_kwargs(self) -> dict[str, t.Any]: + """Default auth dispatch. Domain adapters typically override.""" + auth = getattr(self, "auth", None) + if auth is None: + return {} + return auth_to_driver_kwargs(auth) diff --git a/tests/unit/profiles/test_profile.py b/tests/unit/profiles/test_profile.py new file mode 100644 index 0000000..641b994 --- /dev/null +++ b/tests/unit/profiles/test_profile.py @@ -0,0 +1,165 @@ +# tests/unit/profiles/test_profile.py +"""Unit tests for the generic DescriptorProfile base.""" + +from __future__ import annotations + +import pytest +from pydantic import SecretStr, ValidationError + +from mountainash_settings.auth import NoAuth, PasswordAuth +from mountainash_settings.profiles import ( + DescriptorProfile, + ParameterSpec, + ProfileDescriptor, +) + + +DUMMY_DESCRIPTOR = ProfileDescriptor( + name="dummy", + provider_type="dummy", + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), + ParameterSpec(name="PORT", type=int, tier="core", default=9999, driver_key="port"), + ParameterSpec(name="PASSWORD", type=str, tier="core", secret=True, + driver_key="password", default=None), + ], + auth_modes=[NoAuth, PasswordAuth], +) + + +class DummyProfile(DescriptorProfile): + __descriptor__ = DUMMY_DESCRIPTOR + + +@pytest.mark.unit +class TestDescriptorProfile: + def test_required_field_enforced(self): + with pytest.raises(ValidationError): + DummyProfile(auth=NoAuth()) # HOST missing + + def test_default_used(self): + p = DummyProfile(HOST="localhost", auth=NoAuth()) + assert p.PORT == 9999 + + def test_default_kwargs_noauth(self): + p = DummyProfile(HOST="h", PORT=1234, auth=NoAuth()) + assert p._default_kwargs() == {"host": "h", "port": 1234} + + def test_auth_kwargs_password(self): + p = DummyProfile( + HOST="h", + auth=PasswordAuth(username="u", password=SecretStr("p")), + ) + kwargs = p._auth_kwargs() + assert kwargs["user"] == "u" + assert kwargs["password"] == "p" + + def test_secret_field_unwrapped(self): + p = DummyProfile(HOST="h", PASSWORD="literal-secret", auth=NoAuth()) + kwargs = p._default_kwargs() + assert kwargs["password"] == "literal-secret" + + def test_none_values_skipped(self): + p = DummyProfile(HOST="h", auth=NoAuth()) + kwargs = p._default_kwargs() + assert "password" not in kwargs + + def test_backend_and_profile_name(self): + p = DummyProfile(HOST="h", auth=NoAuth()) + assert p.backend == "dummy" + assert p.profile_name == "dummy" + + def test_provider_type_property(self): + p = DummyProfile(HOST="h", auth=NoAuth()) + assert p.provider_type == "dummy" + + def test_transform_applied(self): + desc = ProfileDescriptor( + name="tf", provider_type="tf", auth_modes=[NoAuth], + parameters=[ + ParameterSpec( + name="FLAG", type=bool, tier="core", + default=True, driver_key="flag", + transform=lambda v: 1 if v else 0, + ), + ], + ) + + class P(DescriptorProfile): + __descriptor__ = desc + + assert P(auth=NoAuth())._default_kwargs() == {"flag": 1} + assert P(FLAG=False, auth=NoAuth())._default_kwargs() == {"flag": 0} + + def test_validator_rejects_bad_input(self): + def _positive(v: int) -> int: + if v <= 0: + raise ValueError("must be positive") + return v + + desc = ProfileDescriptor( + name="val", provider_type="val", auth_modes=[NoAuth], + parameters=[ + ParameterSpec(name="N", type=int, tier="core", + validator=_positive), + ], + ) + + class P(DescriptorProfile): + __descriptor__ = desc + + assert P(N=5, auth=NoAuth()).N == 5 + with pytest.raises(ValidationError, match="must be positive"): + P(N=-1, auth=NoAuth()) + + def test_adapter_owns_pipeline(self): + def _adapter(profile: "DescriptorProfile") -> dict: + kwargs = profile._default_kwargs() + kwargs["adapter_added"] = True + return kwargs + + class Adapted(DescriptorProfile): + __descriptor__ = DUMMY_DESCRIPTOR + __adapter__ = staticmethod(_adapter) + + # Note: DescriptorProfile itself has no to_driver_kwargs; adapters + # are invoked by domain subclasses. We test the mechanism indirectly + # by confirming the adapter attr is accessible. + p = Adapted(HOST="h", auth=NoAuth()) + assert type(p).__dict__.get("__adapter__") is not None + + def test_template_populates_derived_field(self): + """ParameterSpec(template=...) auto-populates field in post_init.""" + desc = ProfileDescriptor( + name="tmpl", provider_type="tmpl", auth_modes=[NoAuth], + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core"), + ParameterSpec(name="URL", type=str, tier="core", + default="", + template="https://{HOST}/api"), + ], + ) + + class P(DescriptorProfile): + __descriptor__ = desc + + p = P(HOST="example.com", auth=NoAuth()) + assert p.URL == "https://example.com/api" + + def test_template_respects_explicit_value(self): + """If caller sets URL explicitly, the template does not overwrite.""" + desc = ProfileDescriptor( + name="tmpl2", provider_type="tmpl2", auth_modes=[NoAuth], + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core"), + ParameterSpec(name="URL", type=str, tier="core", + default="", + template="https://{HOST}/api"), + ], + ) + + class P(DescriptorProfile): + __descriptor__ = desc + + p = P(HOST="a.b", URL="https://override.example/", auth=NoAuth()) + assert p.URL == "https://override.example/" From 66fc1946512d76f1d98acddc357a6b18c583ef88 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Fri, 17 Apr 2026 12:26:17 +1000 Subject: [PATCH 12/13] feat(profiles): add descriptor_invariants_for pytest helper Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_settings/profiles/__init__.py | 2 + .../profiles/invariants.py | 90 +++++++++++++++++++ tests/unit/profiles/test_invariants.py | 41 +++++++++ 3 files changed, 133 insertions(+) create mode 100644 src/mountainash_settings/profiles/invariants.py create mode 100644 tests/unit/profiles/test_invariants.py diff --git a/src/mountainash_settings/profiles/__init__.py b/src/mountainash_settings/profiles/__init__.py index d9335b3..331a4c8 100644 --- a/src/mountainash_settings/profiles/__init__.py +++ b/src/mountainash_settings/profiles/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from .descriptor import MISSING, ParameterSpec, ProfileDescriptor +from .invariants import descriptor_invariants_for from .profile import DescriptorProfile from .registry import Registry @@ -13,4 +14,5 @@ "ParameterSpec", "ProfileDescriptor", "Registry", + "descriptor_invariants_for", ] diff --git a/src/mountainash_settings/profiles/invariants.py b/src/mountainash_settings/profiles/invariants.py new file mode 100644 index 0000000..e8c7c8f --- /dev/null +++ b/src/mountainash_settings/profiles/invariants.py @@ -0,0 +1,90 @@ +# src/mountainash_settings/profiles/invariants.py +"""Parametric descriptor invariants runnable against any Registry. + +Each consumer domain drops this helper into its test suite:: + + from mountainash_settings.profiles import descriptor_invariants_for + from my_package.settings import MY_REGISTRY + + TestMyInvariants = descriptor_invariants_for(MY_REGISTRY) + +Every descriptor registered in ``MY_REGISTRY`` is then checked against the +invariants below. New registrations get coverage for free. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from mountainash_settings.auth.base import AuthSpec + +from .registry import Registry + +__all__ = ["descriptor_invariants_for"] + + +def descriptor_invariants_for(registry: Registry) -> type: + """Return a pytest class parameterised over every descriptor in ``registry``. + + The returned class is named ``TestDescriptorInvariants_``. + """ + + entries = list(registry.descriptors.items()) + ids = list(registry.descriptors.keys()) or [""] + + @pytest.mark.unit + @pytest.mark.parametrize("name,descriptor", entries, ids=ids) + class TestDescriptorInvariants: # noqa: D401 + """Invariants every registered descriptor must satisfy.""" + + def test_name_matches_registry_key(self, name: str, descriptor: t.Any) -> None: + assert descriptor.name == name + + def test_name_lowercase_nonempty(self, name: str, descriptor: t.Any) -> None: + assert descriptor.name, f"{name}: descriptor.name is empty" + assert descriptor.name == descriptor.name.lower(), ( + f"{name}: descriptor.name must be lowercase" + ) + + def test_parameter_names_unique(self, name: str, descriptor: t.Any) -> None: + names = [p.name for p in descriptor.parameters] + assert len(names) == len(set(names)), f"duplicate param in {name}" + + def test_parameter_names_uppercase(self, name: str, descriptor: t.Any) -> None: + for p in descriptor.parameters: + assert p.name == p.name.upper(), ( + f"{name}.{p.name}: ParameterSpec.name must be UPPERCASE" + ) + assert p.name, f"{name}: ParameterSpec.name is empty" + + def test_driver_keys_unique(self, name: str, descriptor: t.Any) -> None: + keys = [p.driver_key for p in descriptor.parameters if p.driver_key] + assert len(keys) == len(set(keys)), f"duplicate driver_key in {name}" + + def test_parameter_tiers_valid(self, name: str, descriptor: t.Any) -> None: + for p in descriptor.parameters: + assert p.tier in {"core", "advanced"}, ( + f"{name}.{p.name} has invalid tier {p.tier!r}" + ) + + def test_auth_modes_nonempty(self, name: str, descriptor: t.Any) -> None: + assert descriptor.auth_modes, ( + f"{name}: auth_modes is empty — use [NoAuth] for no-auth profiles" + ) + + def test_auth_modes_are_authspec(self, name: str, descriptor: t.Any) -> None: + for mode in descriptor.auth_modes: + assert issubclass(mode, AuthSpec), ( + f"{name}.auth_modes contains non-AuthSpec: {mode}" + ) + + def test_provider_type_not_none(self, name: str, descriptor: t.Any) -> None: + assert descriptor.provider_type is not None, ( + f"{name} has no provider_type" + ) + + TestDescriptorInvariants.__name__ = f"TestDescriptorInvariants_{registry.name}" + TestDescriptorInvariants.__qualname__ = TestDescriptorInvariants.__name__ + return TestDescriptorInvariants diff --git a/tests/unit/profiles/test_invariants.py b/tests/unit/profiles/test_invariants.py new file mode 100644 index 0000000..1cf18da --- /dev/null +++ b/tests/unit/profiles/test_invariants.py @@ -0,0 +1,41 @@ +# tests/unit/profiles/test_invariants.py +"""Exercise descriptor_invariants_for against a fake registry.""" + +import pytest + +from mountainash_settings.auth import NoAuth +from mountainash_settings.profiles import ( + ParameterSpec, + ProfileDescriptor, + Registry, +) +from mountainash_settings.profiles.invariants import descriptor_invariants_for + + +FAKE_REGISTRY = Registry("fake_tests") +FAKE_DESC = ProfileDescriptor( + name="fake", + provider_type="fake", + parameters=[ + ParameterSpec(name="HOST", type=str, tier="core", driver_key="host"), + ], + auth_modes=[NoAuth], +) + + +class _FakeProfile: + pass + + +FAKE_REGISTRY.register(FAKE_DESC, _FakeProfile) # type: ignore[arg-type] + + +# Dynamic class — pytest collects its parameterized methods: +TestFakeInvariants = descriptor_invariants_for(FAKE_REGISTRY) + + +@pytest.mark.unit +def test_invariants_class_is_renamed(): + """Sanity check on the name-mangling helper.""" + cls = descriptor_invariants_for(Registry("empty")) + assert cls.__name__ == "TestDescriptorInvariants_empty" From 37efeaa2144b486186886c0ad2e0c5d83ec5e096 Mon Sep 17 00:00:00 2001 From: Nathaniel Ramm Date: Fri, 17 Apr 2026 12:29:23 +1000 Subject: [PATCH 13/13] feat(settings): export profiles + auth from package root Adds MISSING, DescriptorProfile, ParameterSpec, ProfileDescriptor, Registry, descriptor_invariants_for, and the full auth hierarchy (AuthSpec + 10 subclasses, auth_to_driver_kwargs, AUTH_TO_DRIVER_KWARGS) to mountainash_settings.__init__ and __all__. Smoke tests confirm both surfaces are importable from the package root. Co-Authored-By: Claude Sonnet 4.6 --- src/mountainash_settings/__init__.py | 53 +++++++++++++++++++++++++++- tests/unit/test_public_api.py | 30 ++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_public_api.py diff --git a/src/mountainash_settings/__init__.py b/src/mountainash_settings/__init__.py index dbad0ab..545a5a4 100644 --- a/src/mountainash_settings/__init__.py +++ b/src/mountainash_settings/__init__.py @@ -5,6 +5,33 @@ from .settings_cache.settings_functions import get_settings, get_settings_manager from .settings_cache.settings_manager import SettingsManager +# --- Profiles + auth (2026-04-16 promotion) --------------------------------- + +from .profiles import ( + MISSING, + DescriptorProfile, + ParameterSpec, + ProfileDescriptor, + Registry, + descriptor_invariants_for, +) +from .auth import ( + AUTH_TO_DRIVER_KWARGS, + AuthSpec, + AzureADAuth, + CertificateAuth, + IAMAuth, + JWTAuth, + KerberosAuth, + NoAuth, + OAuth2Auth, + PasswordAuth, + ServiceAccountAuth, + TokenAuth, + WindowsAuth, + auth_to_driver_kwargs, +) + __all__ = [ "__version__", @@ -15,4 +42,28 @@ "get_settings", "get_settings_manager", - ] + + # Profiles + "MISSING", + "DescriptorProfile", + "ParameterSpec", + "ProfileDescriptor", + "Registry", + "descriptor_invariants_for", + + # Auth + "AUTH_TO_DRIVER_KWARGS", + "AuthSpec", + "AzureADAuth", + "CertificateAuth", + "IAMAuth", + "JWTAuth", + "KerberosAuth", + "NoAuth", + "OAuth2Auth", + "PasswordAuth", + "ServiceAccountAuth", + "TokenAuth", + "WindowsAuth", + "auth_to_driver_kwargs", +] diff --git a/tests/unit/test_public_api.py b/tests/unit/test_public_api.py new file mode 100644 index 0000000..6385483 --- /dev/null +++ b/tests/unit/test_public_api.py @@ -0,0 +1,30 @@ +"""Smoke test: the new profiles/auth surface is importable from the package root.""" + +import pytest + + +@pytest.mark.unit +def test_profiles_surface_imports(): + from mountainash_settings import ( + MISSING, + DescriptorProfile, + ParameterSpec, + ProfileDescriptor, + Registry, + descriptor_invariants_for, + ) + assert all(obj is not None for obj in ( + MISSING, DescriptorProfile, ParameterSpec, + ProfileDescriptor, Registry, descriptor_invariants_for, + )) + + +@pytest.mark.unit +def test_auth_surface_imports(): + from mountainash_settings import ( + AuthSpec, NoAuth, PasswordAuth, TokenAuth, JWTAuth, OAuth2Auth, + ServiceAccountAuth, IAMAuth, WindowsAuth, AzureADAuth, KerberosAuth, + CertificateAuth, auth_to_driver_kwargs, AUTH_TO_DRIVER_KWARGS, + ) + assert issubclass(PasswordAuth, AuthSpec) + assert callable(auth_to_driver_kwargs)