diff --git a/.github/config/mountainash_dependencies.yml b/.github/config/mountainash_dependencies.yml index 3a7072f..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 @@ -16,8 +16,8 @@ dependencies: # org-name: mountainash-io # - name: mountainash-utils-hamilton # org-name: mountainash-io - - name: mountainash-utils-os - org-name: mountainash-io + # - name: mountainash-utils-os + # org-name: mountainash-io # - name: mountainash-utils-rules # org-name: mountainash-io # - name: mountainash-utils-ssh 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/CLAUDE.md b/CLAUDE.md index 08e35c9..aa2ac57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,81 @@ settings = get_settings(settings_parameters=params) 5. **Multi-Source Configuration**: Environment variables, configuration files, and secret management 6. **Namespace Support**: Isolation and organization of different configuration contexts +## Path Templating with UPath + +**IMPORTANT**: Always use UPath for cross-platform path templates. Do NOT use PLATFORM_SLASH. + +### Correct Pattern + +```python +from pydantic import Field +from upath import UPath +from mountainash_settings import MountainAshBaseSettings + +class MySettings(MountainAshBaseSettings): + ORG_NAME: str = Field(default="acme") + + # ✓ CORRECT: Use UPath's / operator, then convert to string + DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "{ORG_NAME}" / "reports") + ) + + DATA_PATH: str = Field(default=None) + + def post_init(self, reinitialise: bool = False): + super().post_init(reinitialise=reinitialise) + self.DATA_PATH = self.init_setting_from_template( + template_str=self.DATA_PATH_TEMPLATE, + current_value=self.DATA_PATH, + reinitialise=reinitialise + ) +``` + +### Why This Works + +1. **UPath's `/` operator** handles cross-platform paths automatically (POSIX `/`, Windows `\`) +2. **Template placeholders** like `{ORG_NAME}` are preserved in the string +3. **String formatting** happens in `init_setting_from_template()` during `post_init()` +4. **No PLATFORM_SLASH needed** - UPath abstracts platform differences + +### Common Mistakes to Avoid + +```python +# ✗ WRONG: Using backslash operator (syntax error) +WRONG1 = UPath("~" \ "data" \ "{ORG}") + +# ✗ WRONG: Using f-strings with PLATFORM_SLASH (old pattern, deprecated) +from mountainash_utils_os import get_platform_slash +PLATFORM_SLASH = get_platform_slash() +WRONG2: str = Field(default=f"~{PLATFORM_SLASH}data{PLATFORM_SLASH}{{ORG}}") + +# ✓ CORRECT: Use UPath with / operator +CORRECT: str = Field(default=str(UPath("~") / "data" / "{ORG}")) +``` + +### Helper Function Pattern + +For cleaner code with many path components: + +```python +def build_path_template(*parts: str) -> str: + """Build cross-platform path template from parts.""" + path = UPath(parts[0]) + for part in parts[1:]: + path = path / part + return str(path) + +# Usage +REPORT_PATH_TEMPLATE: str = Field( + default=build_path_template("~", "data", "{ORG}", "{DATE}", "reports") +) +``` + +### See Also + +- `examples/path_templating_with_upath.py` - Comprehensive examples and migration guide +- `settings/base_settings.py:187-221` - Template resolution implementation + ## Build/Test/Lint Commands - Build: `hatch build` - Lint: `hatch run ruff:check` or `hatch run ruff:fix` to auto-fix @@ -193,6 +268,7 @@ tests/ - SettingsParameters merging patterns - Runtime type resolution patterns - Enterprise configuration scenarios + - **Path templating with UPath** - Cross-platform path templates without PLATFORM_SLASH ## Versioning Strategy diff --git a/docs/ACRDS_TEMPLATES_FIX.md b/docs/ACRDS_TEMPLATES_FIX.md new file mode 100644 index 0000000..a9700b5 --- /dev/null +++ b/docs/ACRDS_TEMPLATES_FIX.md @@ -0,0 +1,228 @@ +# Exact Fixes for acrds_settings_templates.py + +## Current Issues (Lines 26-38) + +You've correctly switched from backslash to forward slash, but there are two remaining issues: + +1. **Missing `str()` conversion** - Field expects str, not UPath +2. **Double braces** - Use `{VAR}` not `{{VAR}}` (double braces only in f-strings) + +## Line-by-Line Fixes + +### Line 26 - REPORT_BASE_PATH_TEMPLATE + +**CURRENT (has issues):** +```python +REPORT_BASE_PATH_TEMPLATE: str = Field( + default= UPath("~") / "data" / "mountainash" / "{{ORGANISATION_NAME}}" / "{{PORTFOLIO_NAME}}" / "{{RUNDATE}}" / "report" +) +``` + +**FIXED:** +```python +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report") +) +``` + +**Changes:** +- Added `str()` wrapper around the entire UPath expression +- Changed `{{ORGANISATION_NAME}}` to `{ORGANISATION_NAME}` +- Changed `{{PORTFOLIO_NAME}}` to `{PORTFOLIO_NAME}` +- Changed `{{RUNDATE}}` to `{RUNDATE}` + +### Line 27 - RESPONSE_BASE_PATH_TEMPLATE + +**CURRENT:** +```python +RESPONSE_BASE_PATH_TEMPLATE: str = Field( + default=UPath("~") / "data" / "mountainash" / "{{ORGANISATION_NAME}}" / "{{PORTFOLIO_NAME}}" / "{{RUNDATE}}" / "response" +) +``` + +**FIXED:** +```python +RESPONSE_BASE_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "response") +) +``` + +### Lines 30-31 - Derived Paths + +**CURRENT:** +```python +REPORT_DATA_PATH_TEMPLATE: str = Field( + default=UPath("{REPORT_BASE_PATH}") / "report_data" +) +RESPONSE_DATA_PATH_TEMPLATE: str = Field( + default=UPath("{RESPONSE_BASE_PATH}") / "response_data" +) +``` + +**FIXED:** +```python +REPORT_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_data") +) +RESPONSE_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "response_data") +) +``` + +### Lines 33-34 - Flattened Paths + +**CURRENT:** +```python +REPORT_FLATTENED_DATA_PATH_TEMPLATE: str = Field( + default=UPath("{REPORT_BASE_PATH}") / "flattened_report_data" +) +RESPONSE_FLATTENED_DATA_PATH_TEMPLATE: str = Field( + default=UPath("{RESPONSE_BASE_PATH}") / "flattened_response_data" +) +``` + +**FIXED:** +```python +REPORT_FLATTENED_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "flattened_report_data") +) +RESPONSE_FLATTENED_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "flattened_response_data") +) +``` + +### Lines 37-38 - Validation Paths + +**CURRENT:** +```python +REPORT_VALIDATION_DATA_PATH_TEMPLATE: str = Field( + default=UPath("{REPORT_BASE_PATH}") / "report_validation_data" +) +RESPONSE_VALIDATION_DATA_PATH_TEMPLATE: str = Field( + default=UPath("{RESPONSE_BASE_PATH}") / "response_validation_data" +) +``` + +**FIXED:** +```python +REPORT_VALIDATION_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_validation_data") +) +RESPONSE_VALIDATION_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "response_validation_data") +) +``` + +## Why These Changes Matter + +### Issue 1: Missing str() Conversion + +```python +# ❌ WRONG - Type mismatch +TEMPLATE: str = Field(default=UPath("~") / "data") +# Result: UPath object assigned to str field - may cause validation errors + +# ✅ CORRECT - Proper type +TEMPLATE: str = Field(default=str(UPath("~") / "data")) +# Result: String "~/data" assigned to str field +``` + +### Issue 2: Double Braces vs Single Braces + +```python +# ❌ WRONG - Double braces produce literal braces in output +template = UPath("~") / "{{ORG}}" +str(template) # Results in: "~/{ORG}" - wrong! + +# ✅ CORRECT - Single braces for template placeholders +template = UPath("~") / "{ORG}" +str(template) # Results in: "~/{ORG}" - correct! + +# Note: Double braces are ONLY needed in f-strings to escape them +f_string_template = f"~{{ORG}}" # Results in: "~{ORG}" - correct in f-strings +``` + +## Complete Fixed File Section + +Here's the complete fixed section (lines 22-38): + +```python +# File and Path Templates +# Legacy commented out +# REPORT_BASE_PATH_TEMPLATE: str = Field(default=f"~{PLATFORM_SLASH}data{PLATFORM_SLASH}mountainash...") + +# New UPath-based templates +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report") +) + +RESPONSE_BASE_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "response") +) + +REPORT_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_data") +) + +RESPONSE_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "response_data") +) + +REPORT_FLATTENED_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "flattened_report_data") +) + +RESPONSE_FLATTENED_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "flattened_response_data") +) + +REPORT_VALIDATION_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_validation_data") +) + +RESPONSE_VALIDATION_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "response_validation_data") +) + +# Filename templates (no path components, correct as-is) +REPORT_FILENAME_TEMPLATE: str = Field( + default="{ORGANISATION_NAME}_{PORTFOLIO_NAME}_{RUNDATETIME}_{ORGANISATION_TLA}_{RUNDATE}{BATCH_ITERATION}.xml" +) + +RESPONSE_FILENAME_TEMPLATE: str = Field( + default="{ORGANISATION_NAME}_{PORTFOLIO_NAME}_{RUNDATETIME}_{ORGANISATION_TLA}_{RUNDATE}{BATCH_ITERATION}{BUREAU_RESPONSE_SUFFIX}.xml" +) +``` + +## Quick Verification + +After making these changes, verify with: + +```python +from mountainash_acrds_core.settings.acrds_settings_templates import get_acrds_settings_templates + +templates = get_acrds_settings_templates() + +# Check that templates are strings +assert isinstance(templates.REPORT_BASE_PATH_TEMPLATE, str) + +# Check that single braces are preserved +assert "{ORGANISATION_NAME}" in templates.REPORT_BASE_PATH_TEMPLATE +assert "{{ORGANISATION_NAME}}" not in templates.REPORT_BASE_PATH_TEMPLATE + +print("✅ All checks passed!") +print(f"Template: {templates.REPORT_BASE_PATH_TEMPLATE}") +``` + +## Summary + +**Two-step fix for each path template:** + +1. Wrap the entire UPath expression in `str(...)` +2. Change double braces `{{VAR}}` to single braces `{VAR}` + +**Pattern:** +```python +# Before: UPath(...) / "{{VAR}}" +# After: str(UPath(...) / "{VAR}") +``` diff --git a/docs/PATH_TEMPLATING_FIX_GUIDE.md b/docs/PATH_TEMPLATING_FIX_GUIDE.md new file mode 100644 index 0000000..79a4ace --- /dev/null +++ b/docs/PATH_TEMPLATING_FIX_GUIDE.md @@ -0,0 +1,239 @@ +# Path Templating Fix Guide + +## Problem Summary + +The PLATFORM_SLASH approach for cross-platform path templates is a hack that creates brittle, hard-to-read code. The attempt to use backslash operators `\` with UPath results in syntax errors. + +## Solution + +Use UPath's `/` operator for path joining, then convert to string for template storage. + +## Fixing acrds_settings_templates.py + +### Current Code (Line 26 - BROKEN) + +```python +# This has a SYNTAX ERROR - backslash is not a path operator +REPORT_BASE_PATH_TEMPLATE: str = Field( + default= UPath("~" \ "data" \ "mountainash" \ "{{ORGANISATION_NAME}}" \ "{{PORTFOLIO_NAME}}" \ "{{RUNDATE}}" \ "report") +) +``` + +### Fixed Code + +```python +# Option 1: Direct conversion +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=str( + UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report" + ) +) + +# Option 2: With intermediate variable (more readable for long paths) +_report_base = ( + UPath("~") / "data" / "mountainash" / + "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report" +) +REPORT_BASE_PATH_TEMPLATE: str = Field(default=str(_report_base)) + +# Option 3: Using helper function (cleanest for many paths) +def build_path_template(*parts: str) -> str: + """Build cross-platform path template from parts.""" + path = UPath(parts[0]) + for part in parts[1:]: + path = path / part + return str(path) + +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=build_path_template( + "~", "data", "mountainash", + "{ORGANISATION_NAME}", "{PORTFOLIO_NAME}", "{RUNDATE}", "report" + ) +) +``` + +## Step-by-Step Migration + +### Step 1: Remove PLATFORM_SLASH dependency + +**OLD:** +```python +from mountainash_utils_os import get_platform_slash +PLATFORM_SLASH = get_platform_slash() +``` + +**NEW:** +```python +from upath import UPath +# No need for PLATFORM_SLASH at all! +``` + +### Step 2: Convert path templates + +**OLD:** +```python +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=f"~{PLATFORM_SLASH}data{PLATFORM_SLASH}mountainash{PLATFORM_SLASH}{{ORGANISATION_NAME}}{PLATFORM_SLASH}{{PORTFOLIO_NAME}}{PLATFORM_SLASH}{{RUNDATE}}{PLATFORM_SLASH}report" +) +``` + +**NEW:** +```python +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=str( + UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report" + ) +) +``` + +### Step 3: Convert derived path templates + +**OLD:** +```python +REPORT_DATA_PATH_TEMPLATE: str = Field( + default=f"{{REPORT_BASE_PATH}}{PLATFORM_SLASH}report_data" +) +``` + +**NEW:** +```python +REPORT_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_data") +) +``` + +### Step 4: Test cross-platform + +```python +# Test that templates work correctly +settings = AcrdsSettingsTemplates() +print(settings.REPORT_BASE_PATH_TEMPLATE) +# Output on Linux: ~/data/mountainash/{ORGANISATION_NAME}/{PORTFOLIO_NAME}/{RUNDATE}/report +# Output on Windows: ~\data\mountainash\{ORGANISATION_NAME}\{PORTFOLIO_NAME}\{RUNDATE}\report +``` + +## Complete Example for acrds_settings_templates.py + +```python +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict +from functools import lru_cache +from upath import UPath + + +def build_path_template(*parts: str) -> str: + """Helper to build cross-platform path templates.""" + path = UPath(parts[0]) + for part in parts[1:]: + path = path / part + return str(path) + + +class AcrdsSettingsTemplates(BaseSettings): + + model_config = SettingsConfigDict( + env_file=( + "~/.mountainash_acdrs/mountainash_acdrs_file_templates.env", + "mountainash_acdrs_file_templates.env", + ), + extra="ignore", + ) + + # Base path templates - using UPath + REPORT_BASE_PATH_TEMPLATE: str = Field( + default=build_path_template( + "~", "data", "mountainash", + "{ORGANISATION_NAME}", "{PORTFOLIO_NAME}", "{RUNDATE}", "report" + ) + ) + + RESPONSE_BASE_PATH_TEMPLATE: str = Field( + default=build_path_template( + "~", "data", "mountainash", + "{ORGANISATION_NAME}", "{PORTFOLIO_NAME}", "{RUNDATE}", "response" + ) + ) + + # Derived path templates - reference other templates + REPORT_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_data") + ) + + RESPONSE_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "response_data") + ) + + REPORT_FLATTENED_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "flattened_report_data") + ) + + RESPONSE_FLATTENED_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "flattened_response_data") + ) + + REPORT_VALIDATION_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_validation_data") + ) + + RESPONSE_VALIDATION_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "response_validation_data") + ) + + # Filename templates (no path components, just strings) + REPORT_FILENAME_TEMPLATE: str = Field( + default="{ORGANISATION_NAME}_{PORTFOLIO_NAME}_{RUNDATETIME}_{ORGANISATION_TLA}_{RUNDATE}{BATCH_ITERATION}.xml" + ) + + RESPONSE_FILENAME_TEMPLATE: str = Field( + default="{ORGANISATION_NAME}_{PORTFOLIO_NAME}_{RUNDATETIME}_{ORGANISATION_TLA}_{RUNDATE}{BATCH_ITERATION}{BUREAU_RESPONSE_SUFFIX}.xml" + ) + + # Module paths (use dots, not filesystem paths) + APP_METADATA_PATH_TEMPLATE: str = Field( + default="mountainash_acrds_core.config.app_metadata" + ) + + APP_BUILD_REPORT_FIELDMAPPINGS_PATH_TEMPLATE: str = Field( + default="mountainash_acrds_core.config.fieldmappings.report.build.{BATCH_VERSION}" + ) + + # ... rest of the templates ... + + BATCH_ID_TEMPLATE: str = Field( + default="BATCH_{ORGANISATION_NAME}_{PORTFOLIO_NAME}_{RUNDATE}{BATCH_ITERATION}" + ) + + RUNDATETIME_TEMPLATE: str = Field( + default="{RUNDATE}T{RUNTIME}" + ) + + +@lru_cache(maxsize=None) +def get_acrds_settings_templates() -> AcrdsSettingsTemplates: + """Retrieve the AcrdsSettingsTemplates object.""" + return AcrdsSettingsTemplates() +``` + +## Key Takeaways + +1. **Use `/` operator, not `\` operator** - UPath's `/` is the path joining operator +2. **Convert to string** - Use `str(UPath(...))` for template storage +3. **Single braces for templates** - Use `{FIELD}` not `{{FIELD}}` (double braces only in f-strings) +4. **Helper function for readability** - `build_path_template()` makes code cleaner +5. **No PLATFORM_SLASH needed** - UPath handles cross-platform automatically +6. **Test on both platforms** - Verify templates work on Linux and Windows + +## Benefits + +- **Cleaner code**: No ugly f-string concatenation +- **More readable**: Clear path structure with `/` operator +- **Type safe**: UPath provides better type hints +- **Less error-prone**: No manual slash management +- **Future-proof**: Works with cloud paths (s3://, gs://, etc.) via UPath +- **Maintainable**: Easy to understand and modify + +## Reference + +- See `examples/path_templating_with_upath.py` for comprehensive examples +- See `CLAUDE.md` section "Path Templating with UPath" for quick reference +- See `MountainAshBaseSettings.init_setting_from_template()` for template resolution diff --git a/docs/UPATH_QUICK_REFERENCE.md b/docs/UPATH_QUICK_REFERENCE.md new file mode 100644 index 0000000..3d67d70 --- /dev/null +++ b/docs/UPATH_QUICK_REFERENCE.md @@ -0,0 +1,219 @@ +# UPath Quick Reference for Path Templating + +## The Problem You're Solving + +**OLD (BROKEN):** +```python +# Line 26 in acrds_settings_templates.py - SYNTAX ERROR +REPORT_BASE_PATH_TEMPLATE: str = Field( + default= UPath("~" \ "data" \ "mountainash" \ "{{ORGANISATION_NAME}}" \ "{{PORTFOLIO_NAME}}" \ "{{RUNDATE}}" \ "report") +) +``` + +**Why it's broken:** +- `\` is NOT a path joining operator in Python +- You're thinking of using `/` which is UPath's path joining operator + +## The Solution (3 Variations) + +### Option 1: Inline (Simple) + +```python +from upath import UPath +from pydantic import Field + +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report") +) +``` + +### Option 2: Multi-line (More Readable) + +```python +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=str( + UPath("~") / "data" / "mountainash" / + "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report" + ) +) +``` + +### Option 3: Helper Function (Best for Many Paths) + +```python +def build_path_template(*parts: str) -> str: + path = UPath(parts[0]) + for part in parts[1:]: + path = path / part + return str(path) + +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=build_path_template("~", "data", "mountainash", "{ORGANISATION_NAME}", "{PORTFOLIO_NAME}", "{RUNDATE}", "report") +) +``` + +## Cheat Sheet + +| Task | OLD (Don't Use) | NEW (Use This) | +|------|----------------|----------------| +| Join paths | `f"~{PLATFORM_SLASH}data"` | `str(UPath("~") / "data")` | +| With template | `f"~{PLATFORM_SLASH}{{ORG}}"` | `str(UPath("~") / "{ORG}")` | +| Derived path | `f"{{BASE}}{PLATFORM_SLASH}sub"` | `str(UPath("{BASE}") / "sub")` | +| Wrong operator | `UPath("~" \ "data")` ❌ | `UPath("~") / "data"` ✅ | + +## Common Mistakes + +### Mistake 1: Using backslash `\` +```python +# ❌ WRONG - Syntax error +UPath("~" \ "data") + +# ✅ CORRECT - Use forward slash +UPath("~") / "data" +``` + +### Mistake 2: Forgetting to convert to string +```python +# ❌ WRONG - Field expects str, not UPath +TEMPLATE: str = Field(default=UPath("~") / "data") + +# ✅ CORRECT - Convert to string +TEMPLATE: str = Field(default=str(UPath("~") / "data")) +``` + +### Mistake 3: Double braces in non-f-strings +```python +# ❌ WRONG - Double braces only needed in f-strings +TEMPLATE = str(UPath("~") / "{{ORG}}") # Results in literal {{ORG}} + +# ✅ CORRECT - Single braces for templates +TEMPLATE = str(UPath("~") / "{ORG}") +``` + +## How Template Resolution Works + +```python +# 1. Define template (at class level) +DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "{ORG_NAME}") +) + +# 2. Define resolved field (starts as None) +DATA_PATH: str = Field(default=None) + +# 3. Resolve in post_init() +def post_init(self, reinitialise: bool = False): + super().post_init(reinitialise=reinitialise) + self.DATA_PATH = self.init_setting_from_template( + template_str=self.DATA_PATH_TEMPLATE, + current_value=self.DATA_PATH, + reinitialise=reinitialise + ) + +# Result: If ORG_NAME="acme" +# DATA_PATH_TEMPLATE = "~/data/{ORG_NAME}" +# DATA_PATH = "~/data/acme" +``` + +## Resolution Order Matters + +```python +# ✅ CORRECT - Base paths resolved first +def post_init(self, reinitialise: bool = False): + super().post_init(reinitialise=reinitialise) + + # 1. Resolve base paths + self.BASE_PATH = self.init_setting_from_template( + template_str=self.BASE_PATH_TEMPLATE, + current_value=self.BASE_PATH, + reinitialise=reinitialise + ) + + # 2. Then resolve derived paths (that reference BASE_PATH) + self.DERIVED_PATH = self.init_setting_from_template( + template_str=self.DERIVED_PATH_TEMPLATE, # Contains {BASE_PATH} + current_value=self.DERIVED_PATH, + reinitialise=reinitialise + ) +``` + +## Converting from PLATFORM_SLASH + +### Step 1: Remove import +```python +# ❌ REMOVE THIS +from mountainash_utils_os import get_platform_slash +PLATFORM_SLASH = get_platform_slash() + +# ✅ ADD THIS +from upath import UPath +``` + +### Step 2: Convert each path template +```python +# ❌ OLD +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=f"~{PLATFORM_SLASH}data{PLATFORM_SLASH}mountainash{PLATFORM_SLASH}{{ORGANISATION_NAME}}{PLATFORM_SLASH}{{PORTFOLIO_NAME}}{PLATFORM_SLASH}{{RUNDATE}}{PLATFORM_SLASH}report" +) + +# ✅ NEW +REPORT_BASE_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report") +) +``` + +### Step 3: Convert derived templates +```python +# ❌ OLD +REPORT_DATA_PATH_TEMPLATE: str = Field( + default=f"{{REPORT_BASE_PATH}}{PLATFORM_SLASH}report_data" +) + +# ✅ NEW +REPORT_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_data") +) +``` + +## Why This Is Better + +| Aspect | PLATFORM_SLASH | UPath | +|--------|---------------|-------| +| **Readability** | `f"~{PS}data{PS}{{ORG}}"` 😵 | `str(UPath("~") / "data" / "{ORG}")` ✨ | +| **Maintainability** | Hard to modify | Easy to change | +| **Cross-platform** | Manual platform detection | Automatic | +| **Error-prone** | Easy to miss a slash | Type-safe | +| **Cloud paths** | Doesn't work | Works (s3://, gs://) | +| **Code cleanliness** | Ugly concatenation | Clean operators | + +## Testing Your Changes + +```python +# Test template creation +settings = AcrdsSettingsTemplates() +print(settings.REPORT_BASE_PATH_TEMPLATE) +# Linux: ~/data/mountainash/{ORGANISATION_NAME}/{PORTFOLIO_NAME}/{RUNDATE}/report +# Windows: ~\data\mountainash\{ORGANISATION_NAME}\{PORTFOLIO_NAME}\{RUNDATE}\report + +# Test template resolution (assuming you have a settings class that uses these) +app_settings = YourAppSettings(ORGANISATION_NAME="acme", PORTFOLIO_NAME="prod", RUNDATE="20250111") +print(app_settings.REPORT_BASE_PATH) +# Linux: ~/data/mountainash/acme/prod/20250111/report +# Windows: ~\data\mountainash\acme\prod\20250111\report +``` + +## Complete Examples + +See these files for full working examples: +- `examples/path_templating_with_upath.py` - Comprehensive examples with 4 patterns +- `docs/PATH_TEMPLATING_FIX_GUIDE.md` - Detailed migration guide +- `CLAUDE.md` - Project-specific guidance + +## One-Liner Reminder + +**Use `/` to join UPath components, then convert to string for Field defaults.** + +```python +# This is the pattern: +Field(default=str(UPath("part1") / "part2" / "{TEMPLATE_VAR}")) +``` diff --git a/docs/superpowers/plans/2026-04-02-architecture-cleanup.md b/docs/superpowers/plans/2026-04-02-architecture-cleanup.md new file mode 100644 index 0000000..1357be0 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-architecture-cleanup.md @@ -0,0 +1,1151 @@ +# Architecture Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix two correctness bugs (cached object mutation, secrets_dir misclassification), remove namespace, collapse the merge framework, and clean up dead code. + +**Architecture:** Seven tasks executed in dependency order. P0 bugs first (safe, isolated fixes), then P1 simplifications (namespace removal, merge collapse, SettingsUtils removal), then P2 cleanup. Each task produces a passing test suite before moving on. + +**Tech Stack:** Python, pydantic-settings, pytest, hatch + +--- + +### Task 1: Fix cached object mutation in SettingsManager.get_settings_object() + +**Files:** +- Modify: `src/mountainash_settings/settings_cache/settings_manager.py:34-55` +- Modify: `tests/test_settings_manager.py:197-253` + +- [ ] **Step 1: Write the failing test that proves the mutation bug** + +Add to `tests/test_settings_manager.py` in the `TestGetSettingsObject` class: + +```python +@pytest.mark.unit +def test_runtime_overrides_do_not_mutate_cached_instance(self, isolated_settings_manager): + """Test that runtime override kwargs do NOT mutate the cached instance.""" + # Create and cache settings + params_create = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="original_value" + ) + created_settings = isolated_settings_manager.get_or_create_settings(params_create) + assert created_settings.TEST_VAL_1 == "original_value" + + # Retrieve with override kwargs + params_override = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="overridden_value" + ) + retrieved_settings = isolated_settings_manager.get_settings_object(params_override) + assert retrieved_settings.TEST_VAL_1 == "overridden_value" + + # The CACHED instance must NOT have been mutated + cached_directly = isolated_settings_manager.settings_object_cache[params_create] + assert cached_directly.TEST_VAL_1 == "original_value" +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `hatch run test:test tests/test_settings_manager.py::TestGetSettingsObject::test_runtime_overrides_do_not_mutate_cached_instance -v` + +Expected: FAIL -- `cached_directly.TEST_VAL_1` is `"overridden_value"` instead of `"original_value"` + +- [ ] **Step 3: Fix SettingsManager.get_settings_object() to copy before mutating** + +In `src/mountainash_settings/settings_cache/settings_manager.py`, replace the `get_settings_object` method: + +```python +def get_settings_object(self, settings_parameters: SettingsParameters) -> MountainAshBaseSettings: + """ + Gets the configuration object for a given set of parameters. + + If the parameters contain runtime override kwargs, returns a copy + with overrides applied. The cached instance is never mutated. + + Args: + settings_parameters: The parameters identifying the settings. + Returns: + MountainAshBaseSettings: The settings object, possibly with runtime overrides. + Raises: + ValueError: If the cached object is not a MountainAshBaseSettings instance. + """ + obj_settings: Optional[MountainAshBaseSettings] = self.settings_object_cache.get(settings_parameters, None) + + if not isinstance(obj_settings, MountainAshBaseSettings): + raise ValueError( + f"Configuration for '{settings_parameters}' found, but is not a " + f"MountainAshBaseSettings object. Received a {type(obj_settings)}" + ) + + override_kwargs = settings_parameters.get_attribute_settings_kwargs() + if override_kwargs: + obj_settings = obj_settings.model_copy() + obj_settings.update_settings_from_dict(settings_dict=override_kwargs) + + return obj_settings +``` + +- [ ] **Step 4: Update the existing test that relied on mutation behavior** + +In `tests/test_settings_manager.py`, update `TestGetSettingsObject.test_applies_runtime_override_kwargs` to reflect the new correct behavior: + +```python +@pytest.mark.unit +def test_applies_runtime_override_kwargs(self, isolated_settings_manager): + """Test that runtime override kwargs are applied to a copy, not the cached instance.""" + # Create settings without override + params_create = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="original_value" + ) + created_settings = isolated_settings_manager.get_or_create_settings(params_create) + assert created_settings.TEST_VAL_1 == "original_value" + + # Retrieve with override kwargs + params_override = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="overridden_value" + ) + retrieved_settings = isolated_settings_manager.get_settings_object(params_override) + + # Retrieved copy has the override + assert retrieved_settings.TEST_VAL_1 == "overridden_value" + # Original cached instance is untouched + assert created_settings.TEST_VAL_1 == "original_value" +``` + +- [ ] **Step 5: Run all tests to verify the fix** + +Run: `hatch run test:test tests/test_settings_manager.py -v` + +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/mountainash_settings/settings_cache/settings_manager.py tests/test_settings_manager.py +git commit -m "fix: prevent mutation of cached settings in SettingsManager.get_settings_object() + +Copy the cached instance before applying runtime override kwargs, +preventing silent data corruption where one caller's overrides +permanently alter the cached object for all subsequent callers." +``` + +--- + +### Task 2: Move secrets_dir to structural parameters + +**Files:** +- Modify: `src/mountainash_settings/settings_parameters/settings_parameters.py:92-160` +- Modify: `tests/test_settings_parameters/test_settings_parameters_coverage.py:86-102` +- Modify: `tests/test_settings_parameters/test_settings_parameters.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_settings_parameters/test_settings_parameters_coverage.py`, replace the `test_eq_ignores_secrets_dir_differences` test: + +```python +@pytest.mark.unit +def test_eq_differs_on_secrets_dir(self): + """Test that different secrets_dir values produce inequality (structural param).""" + params1 = SettingsParameters.create( + settings_class=TestSettings, + secrets_dir="/secrets1" + ) + params2 = SettingsParameters.create( + settings_class=TestSettings, + secrets_dir="/secrets2" + ) + + # secrets_dir is structural -- different values should NOT be equal + assert params1 != params2 + assert hash(params1) != hash(params2) +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `hatch run test:test tests/test_settings_parameters/test_settings_parameters_coverage.py::TestEquality::test_eq_differs_on_secrets_dir -v` + +Expected: FAIL -- params are currently equal because secrets_dir is excluded from hash/eq + +- [ ] **Step 3: Add secrets_dir to __hash__ and __eq__** + +In `src/mountainash_settings/settings_parameters/settings_parameters.py`, update `__hash__`: + +```python +def __hash__(self): + """ + Custom hash implementation for efficient settings caching strategy. + + Includes all structural parameters that define the core configuration identity: + - config_files: Source configuration files + - settings_class: Type of settings object + - env_prefix: Environment variable prefix + - secrets_dir: Directory for secrets storage + + Deliberately EXCLUDES runtime parameters (kwargs) to enable + cache reuse when only dynamic overrides differ. + + Returns: + int: Hash value based on structural parameters only + """ + hashable_config_files = SettingsFileHandler.format_config_file_tuple(self.config_files) + + hashable_attrs = tuple([ + hashable_config_files, + self.settings_class, + self.env_prefix, + self.secrets_dir, + # Deliberately exclude: self.kwargs + ]) + + return hash(hashable_attrs) +``` + +Update `__eq__`: + +```python +def __eq__(self, other): + """ + Equality based on the same structural parameters used in __hash__. + + Two SettingsParameters are equal if their core configuration identity + matches, regardless of runtime parameter differences. + + Args: + other: Object to compare with + + Returns: + bool: True if structural parameters match, False otherwise + """ + if not isinstance(other, SettingsParameters): + return False + + self_hashable_config_files = SettingsFileHandler.format_config_file_tuple(self.config_files) + other_hashable_config_files = SettingsFileHandler.format_config_file_tuple(other.config_files) + + return ( + self_hashable_config_files == other_hashable_config_files and + self.settings_class == other.settings_class and + self.env_prefix == other.env_prefix and + self.secrets_dir == other.secrets_dir + # Deliberately exclude: kwargs comparison + ) +``` + +Note: `namespace` is still included in hash/eq for now -- it will be removed in Task 3. + +The updated `__hash__`: + +```python +def __hash__(self): + hashable_config_files = SettingsFileHandler.format_config_file_tuple(self.config_files) + + hashable_attrs = tuple([ + self.namespace, + hashable_config_files, + self.settings_class, + self.env_prefix, + self.secrets_dir, + # Deliberately exclude: self.kwargs + ]) + + return hash(hashable_attrs) +``` + +The updated `__eq__`: + +```python +def __eq__(self, other): + if not isinstance(other, SettingsParameters): + return False + + self_hashable_config_files = SettingsFileHandler.format_config_file_tuple(self.config_files) + other_hashable_config_files = SettingsFileHandler.format_config_file_tuple(other.config_files) + + return ( + self.namespace == other.namespace and + self_hashable_config_files == other_hashable_config_files and + self.settings_class == other.settings_class and + self.env_prefix == other.env_prefix and + self.secrets_dir == other.secrets_dir + # Deliberately exclude: kwargs comparison + ) +``` + +- [ ] **Step 4: Run the full test suite** + +Run: `hatch run test:test tests/test_settings_parameters/ -v` + +Expected: The new `test_eq_differs_on_secrets_dir` passes. The existing `test_eq_ignores_secrets_dir_differences` will now fail -- delete it since it tested the old (incorrect) behavior. + +- [ ] **Step 5: Commit** + +```bash +git add src/mountainash_settings/settings_parameters/settings_parameters.py tests/test_settings_parameters/test_settings_parameters_coverage.py +git commit -m "fix: classify secrets_dir as structural parameter in hash/eq + +secrets_dir is a configuration source (pydantic-settings reads from it), +not a runtime override. Two parameter sets with different secrets_dir +values must produce different cache entries." +``` + +--- + +### Task 3: Remove namespace field + +**Files:** +- Modify: `src/mountainash_settings/settings_parameters/settings_parameters.py` +- Modify: `src/mountainash_settings/settings/base_settings.py` +- Modify: `src/mountainash_settings/settings_cache/settings_functions.py` +- Modify: `src/mountainash_settings/settings_cache/settings_manager.py` +- Modify: `src/mountainash_settings/settings_parameters/merge_framework.py` +- Modify: `src/mountainash_settings/settings_parameters/utils.py` +- Modify: `tests/test_base_settings.py` +- Modify: `tests/test_base_settings_coverage.py` +- Modify: `tests/test_settings_manager.py` +- Modify: `tests/test_settings_parameters/test_settings_parameters.py` +- Modify: `tests/test_settings_parameters/test_settings_parameters_coverage.py` +- Modify: `tests/test_settings_parameters/test_merge_framework.py` +- Modify: `tests/fixtures/parameters.py` +- Modify: `tests/fixtures/instances.py` + +This is the largest task. The approach: remove namespace from the dataclass and all source code first, then fix all tests. + +- [ ] **Step 1: Remove namespace from SettingsParameters dataclass** + +In `src/mountainash_settings/settings_parameters/settings_parameters.py`: + +1. Remove `namespace: Optional[str] = None` from the dataclass fields +2. Remove `self.namespace` from `__hash__` hashable_attrs +3. Remove `self.namespace == other.namespace` from `__eq__` +4. Remove `namespace` parameter from `create()` classmethod +5. Remove `namespace=namespace` from the `create()` return +6. Remove `_init_namespace()` static method +7. Remove `'namespace': self.namespace` from `to_dict()` +8. Update the docstring to remove namespace references + +The `create()` method becomes: + +```python +@classmethod +def create(cls, + config_files: Optional[str|UPath|List[str|UPath]|Tuple[str|UPath]] = None, + settings_class: Optional[Type[BaseSettings]] = None, + env_prefix: Optional[str] = None, + secrets_dir: Optional[str] = None, + **kwargs: Any + ) -> 'SettingsParameters': + + resolved_config_files = SettingsFileHandler.format_config_file_tuple(config_files) + resolved_kwargs = SettingsKwargsHandler.format_kwargs_dict(kwargs) if kwargs else None + + return cls( + config_files=resolved_config_files, + settings_class=settings_class, + env_prefix=env_prefix, + secrets_dir=secrets_dir, + kwargs=resolved_kwargs + ) +``` + +The `to_dict()` method becomes: + +```python +def to_dict(self) -> Dict[str, Any]: + return { + 'config_files': list(self.config_files) if self.config_files else None, + 'kwargs': self.get_all_kwargs() if self.kwargs else None, + 'settings_class': self.settings_class, + 'env_prefix': self.env_prefix, + 'secrets_dir': self.secrets_dir + } +``` + +- [ ] **Step 2: Remove SETTINGS_NAMESPACE from MountainAshBaseSettings** + +In `src/mountainash_settings/settings/base_settings.py`: + +1. Remove `SETTINGS_NAMESPACE: str = Field(default=None)` field declaration +2. Remove `setattr(self, "SETTINGS_NAMESPACE", local_settings_params.namespace)` from `__init__` +3. Remove `self.SETTINGS_NAMESPACE` from `__hash__` +4. Remove `settings_namespace` parameter from `get_settings()` classmethod +5. Remove `settings_namespace=settings_namespace` from the `get_settings()` call inside +6. Remove `existing_namespace` from `extract_settings_parameters()` and `namespace=existing_namespace` from the `SettingsParameters.create()` call + +The `__hash__` becomes: + +```python +def __hash__(self) -> int: + return hash(( + self.SETTINGS_CLASS_NAME, + tuple(self.SETTINGS_SOURCE_ENV_FILES) if self.SETTINGS_SOURCE_ENV_FILES else None, + tuple(self.SETTINGS_SOURCE_ENV_PREFIX) if self.SETTINGS_SOURCE_ENV_PREFIX else None, + tuple(self.SETTINGS_SOURCE_YAML_FILES) if self.SETTINGS_SOURCE_YAML_FILES else None, + tuple(self.SETTINGS_SOURCE_TOML_FILES) if self.SETTINGS_SOURCE_TOML_FILES else None, + tuple(self.SETTINGS_SOURCE_JSON_FILES) if self.SETTINGS_SOURCE_JSON_FILES else None, + )) +``` + +The `get_settings()` classmethod becomes: + +```python +@classmethod +def get_settings(cls, + settings_parameters: Optional[SettingsParameters] = None, + settings_class: Optional[Type[T]] = None, + config_files: Optional[Union[UPath, str, List[UPath|str]]] = None, + env_prefix: Optional[str] = None, + **kwargs + ) -> Any: + from mountainash_settings.settings_cache import get_settings + + if settings_class is None: + class_module = cls.__module__ + class_name = cls.__name__ + settings_class = getattr(import_module(name=class_module), class_name) + + settings_instance: Any = get_settings( + settings_parameters=settings_parameters, + settings_class=settings_class, + config_files=config_files, + env_prefix=env_prefix, + **kwargs + ) + + if not isinstance(settings_instance, cls): + raise TypeError( + f"Created instance of type {type(settings_instance).__name__} " + f"but expected {cls.__name__} when calling {cls.__name__}.get_settings()" + ) + + return settings_instance +``` + +The `extract_settings_parameters()` becomes: + +```python +def extract_settings_parameters(self) -> SettingsParameters: + config_files: List = [] + if self.SETTINGS_SOURCE_ENV_FILES: + config_files += self.SETTINGS_SOURCE_ENV_FILES + if self.SETTINGS_SOURCE_YAML_FILES: + config_files += self.SETTINGS_SOURCE_YAML_FILES + if self.SETTINGS_SOURCE_TOML_FILES: + config_files += self.SETTINGS_SOURCE_TOML_FILES + if self.SETTINGS_SOURCE_JSON_FILES: + config_files += self.SETTINGS_SOURCE_JSON_FILES + + existing_config_files = SettingsUtils.format_config_file_list(config_files=config_files) + existing_kwargs = SettingsUtils.format_kwargs_dict(p_kwargs=self.SETTINGS_SOURCE_KWARGS) + existing_settings_class = self.SETTINGS_CLASS or None + existing_env_prefix = self.SETTINGS_SOURCE_ENV_PREFIX or None + + params: SettingsParameters = SettingsParameters.create( + settings_class=existing_settings_class, + config_files=existing_config_files, + kwargs=existing_kwargs, + env_prefix=existing_env_prefix) + + return params +``` + +- [ ] **Step 3: Remove settings_namespace from get_settings() function** + +In `src/mountainash_settings/settings_cache/settings_functions.py`: + +1. Remove `settings_namespace` parameter from `get_settings()` +2. Remove `namespace=settings_namespace` from both `SettingsParameters.create()` calls + +The `get_settings()` function becomes: + +```python +def get_settings( settings_parameters: Optional[SettingsParameters] = None, + settings_class: Optional[Type[MountainAshBaseSettings]] = None, + config_files: Optional[Union[UPath, str, List[UPath|str]]] = None, + env_prefix: Optional[str] = None, + **kwargs + ) -> BaseSettings: + """ + The main function to retrieve application settings. + + Args: + settings_parameters: Pre-built settings parameters object. + settings_class: The class of settings to create. + config_files: Configuration files to load. + env_prefix: Environment variable prefix. + **kwargs: Additional keyword arguments passed as runtime overrides. + + Returns: + BaseSettings: The settings instance. + """ + if settings_parameters: + if not isinstance(settings_parameters, SettingsParameters): + raise ValueError("The settings_parameters parameter must be an instance of SettingsParameters.") + + local_settings_parameters = SettingsParameters.create( + settings_class=settings_class, + config_files=config_files, + env_prefix=env_prefix, + **kwargs + ) + + final_settings_parameters = SettingsUtils.merge_settings_parameter_objects(settings_parameters, local_settings_parameters) + + else: + final_settings_parameters = SettingsParameters.create( + settings_class=settings_class, + config_files=config_files, + env_prefix=env_prefix, + **kwargs + ) + + cached_settings = _get_settings(settings_parameters=final_settings_parameters) + return final_settings_parameters.apply_runtime_overrides(cached_settings) +``` + +- [ ] **Step 4: Remove namespace from merge framework** + +In `src/mountainash_settings/settings_parameters/merge_framework.py`: + +1. Remove `resolved_namespace` logic from `merge_with_object()` (lines 83-91, 115) +2. Remove `namespace` parameter and logic from `merge_with_params()` (lines 124, 139-147, 167) +3. Remove `merge_namespaces()` from `FieldMergeUtils` + +In `src/mountainash_settings/settings_parameters/utils.py`: + +1. Remove `default_namespace` class attribute +2. Remove `namespace` parameter from `merge_settings_parameters()` +3. Remove `merge_namespaces()` method + +- [ ] **Step 5: Remove namespace from SettingsManager docstrings** + +In `src/mountainash_settings/settings_cache/settings_manager.py`: + +1. Update docstrings to remove namespace references (no code changes needed beyond docstrings since `SettingsManager` uses `SettingsParameters` as keys, not namespace strings directly) +2. Rename `is_namespace_initialised` to `is_initialised` for clarity + +- [ ] **Step 6: Update ALL tests to remove namespace= arguments** + +This is a bulk operation across many test files. For each file: + +**`tests/test_base_settings.py`:** Remove all `namespace=` arguments from `SettingsParameters.create()` calls. Each test currently uses a unique namespace for cache isolation -- since namespace is removed, tests that need distinct cache entries must differ on other structural params (different config files, env_prefix, or settings_class). For tests that are identical except for namespace, either: +- Use the `isolated_settings_manager` fixture (which creates a fresh `SettingsManager`) +- Add a unique kwarg that doesn't affect cache identity + +Remove `settings_namespace=` from `get_test_settings()` function signature and calls. + +**`tests/test_base_settings_coverage.py`:** +- Remove all `namespace=` from `SettingsParameters.create()` calls +- Remove `test_hash_different_namespaces` test entirely (namespace is gone) +- Remove `settings_namespace=` from `TestSettings.get_settings()` calls +- Update `test_extract_basic_parameters` etc. to not assert on `extracted.namespace` +- Update `test_full_workflow_with_templates` to not assert `params.namespace` + +**`tests/test_settings_manager.py`:** +- Remove all `namespace=` from `SettingsParameters.create()` calls +- Rename `is_namespace_initialised` to `is_initialised` in test method calls +- For `test_different_namespaces_create_different_settings`: change to use different `env_prefix` values to ensure distinct cache entries +- For `test_multiple_settings_in_cache`: use different `env_prefix` values + +**`tests/test_settings_parameters/test_settings_parameters.py`:** +- Remove all `namespace=` from `SettingsParameters` and `SettingsParameters.create()` calls +- Remove `assert params.namespace` assertions +- Remove `test_init_namespace_*` tests +- Remove `'namespace'` from `to_dict` assertions +- Update `test_hash_different_for_different_params` to use different env_prefix instead + +**`tests/test_settings_parameters/test_settings_parameters_coverage.py`:** +- Remove all `namespace=` from `SettingsParameters.create()` calls +- Remove `test_eq_differs_on_namespace` test +- Update integration tests to not assert on namespace +- Remove `assert settings.SETTINGS_NAMESPACE` assertions + +**`tests/test_settings_parameters/test_merge_framework.py`:** +- Remove all `namespace=` from `SettingsParameters.create()` calls +- Remove all `TestSettingsParameterMergerObject` tests about namespace merging +- Remove all `TestSettingsParameterMergerParams` tests about namespace merging +- Remove `TestFieldMergeUtils.test_merge_namespaces_*` tests +- Update integration tests to not assert on namespace + +**`tests/fixtures/parameters.py`:** +- Remove all `namespace=` from `SettingsParameters.create()` calls +- Remove `parametrized_namespace` fixture +- Remove `namespace` parameter from `create_settings_parameters` factory + +**`tests/fixtures/instances.py`:** +- Remove `namespace=` from `settings_with_runtime_override` fixture + +- [ ] **Step 7: Run the full test suite** + +Run: `hatch run test:test -v` + +Expected: ALL PASS. If any test fails, examine the failure -- it's likely a missed namespace reference. Fix and re-run. + +- [ ] **Step 8: Commit** + +```bash +git add -u +git commit -m "refactor: remove namespace field from settings architecture + +Namespace was a legacy cache discriminator with no behavioral effect +on settings loading, resolution, or scoping. Cache identity is now +determined entirely by config_files, settings_class, env_prefix, +and secrets_dir -- the parameters that actually affect what values +are produced." +``` + +--- + +### Task 4: Collapse merge framework to single entry point + +**Files:** +- Modify: `src/mountainash_settings/settings_parameters/merge_framework.py` +- Modify: `src/mountainash_settings/settings_parameters/utils.py` +- Modify: `src/mountainash_settings/settings_parameters/__init__.py` +- Modify: `src/mountainash_settings/__init__.py` +- Modify: `src/mountainash_settings/settings/base_settings.py` +- Modify: `src/mountainash_settings/settings_cache/settings_functions.py` +- Modify: `src/mountainash_settings/settings_cache/settings_manager.py` +- Modify: `tests/test_settings_parameters/test_merge_framework.py` + +- [ ] **Step 1: Add a merge() classmethod to SettingsParameters** + +In `src/mountainash_settings/settings_parameters/settings_parameters.py`, add after the `create()` method: + +```python +@classmethod +def merge(cls, + base: 'SettingsParameters', + other: Optional['SettingsParameters'] = None, + prioritise_base: bool = False + ) -> 'SettingsParameters': + """ + Merge two SettingsParameters objects. + + Per-field strategies: + - config_files: combined and deduplicated + - settings_class: must match if both provided (raises ValueError) + - scalars (env_prefix, secrets_dir): last wins (or first if prioritise_base) + - kwargs: merged dict, second takes precedence (or first if prioritise_base) + + Args: + base: The base parameters. + other: Parameters to merge in. If None, returns base. + prioritise_base: If True, base values win over other values. + + Returns: + A new SettingsParameters with merged values. + + Raises: + ValueError: If base is None or settings_class values conflict. + """ + if base is None: + raise ValueError("Base SettingsParameters cannot be None") + if other is None: + return base + + # Config files: combine and deduplicate + if base.config_files is None and other.config_files is None: + merged_config_files = None + elif prioritise_base: + merged_config_files = base.config_files or other.config_files + else: + merged = set(base.config_files or ()) | set(other.config_files or ()) + merged_config_files = tuple(sorted(str(p) for p in merged)) if merged else None + + # Settings class: validate compatibility + if base.settings_class is not None and other.settings_class is not None: + if base.settings_class != other.settings_class: + raise ValueError( + f"Settings class must match for merging. " + f"base: {base.settings_class} != other: {other.settings_class}" + ) + if prioritise_base: + merged_class = base.settings_class or other.settings_class + else: + merged_class = other.settings_class or base.settings_class + + # Scalars: simple priority + if prioritise_base: + merged_env_prefix = base.env_prefix or other.env_prefix + merged_secrets_dir = base.secrets_dir or other.secrets_dir + else: + merged_env_prefix = other.env_prefix or base.env_prefix + merged_secrets_dir = other.secrets_dir or base.secrets_dir + + # Kwargs: merge dicts + if base.kwargs is None and other.kwargs is None: + merged_kwargs = None + elif prioritise_base: + merged_kwargs = base.kwargs or other.kwargs + else: + merged_kwargs = dict(base.kwargs or {}) | dict(other.kwargs or {}) + merged_kwargs = merged_kwargs.get("kwargs", merged_kwargs) + merged_kwargs = merged_kwargs if merged_kwargs else None + + return cls.create( + settings_class=merged_class, + config_files=merged_config_files, + env_prefix=merged_env_prefix, + secrets_dir=merged_secrets_dir, + **(merged_kwargs or {}) + ) +``` + +- [ ] **Step 2: Update all callers to use SettingsParameters.merge()** + +In `src/mountainash_settings/settings/base_settings.py`, line ~58: + +Replace: +```python +local_settings_params = SettingsUtils.merge_settings_parameter_objects(settings_parameters, local_settings_params) +``` +With: +```python +local_settings_params = SettingsParameters.merge(settings_parameters, local_settings_params) +``` + +In `src/mountainash_settings/settings_cache/settings_functions.py`, line ~95: + +Replace: +```python +final_settings_parameters = SettingsUtils.merge_settings_parameter_objects(settings_parameters, local_settings_parameters) +``` +With: +```python +final_settings_parameters = SettingsParameters.merge(settings_parameters, local_settings_parameters) +``` + +- [ ] **Step 3: Strip merge_framework.py down to just ValidationError** + +Replace `src/mountainash_settings/settings_parameters/merge_framework.py` with: + +```python +""" +Validation utilities for settings parameter operations. +""" + + +class ValidationError(Exception): + """Exception for validation failures in settings parameter operations.""" + pass +``` + +The four module-level merge functions (`_merge_simple`, `_merge_config_files`, `_merge_kwargs`, `_merge_settings_class`) are no longer needed -- their logic is now inline in `SettingsParameters.merge()`. The classes `SettingsParameterMerger`, `FieldMergeUtils`, `GenericMerger`, `MergePriority`, and `get_merger` are removed. + +- [ ] **Step 4: Update __init__.py exports** + +In `src/mountainash_settings/settings_parameters/__init__.py`: + +```python +from .filehandler import SettingsFileHandler, SettingsFiles +from .kwargshandler import SettingsKwargsHandler +from .settings_parameters import SettingsParameters +from .merge_framework import ValidationError + +__all__ = [ + "SettingsParameters", + "SettingsFileHandler", + "SettingsKwargsHandler", + "SettingsFiles", + "ValidationError", +] +``` + +- [ ] **Step 5: Update tests for the merge framework** + +Rewrite `tests/test_settings_parameters/test_merge_framework.py` to test `SettingsParameters.merge()` directly: + +```python +""" +Tests for SettingsParameters.merge() method. +""" + +import pytest +from mountainash_settings import SettingsParameters +from mountainash_settings.settings_parameters.merge_framework import ValidationError +from fixtures.settings_classes import TestSettings, MockBaseSettings + + +class TestMerge: + """Test SettingsParameters.merge() method.""" + + @pytest.mark.unit + def test_merge_raises_error_if_base_none(self): + other = SettingsParameters.create(settings_class=TestSettings) + with pytest.raises(ValueError, match="Base SettingsParameters cannot be None"): + SettingsParameters.merge(None, other) + + @pytest.mark.unit + def test_merge_with_none_other_returns_base(self): + base = SettingsParameters.create(settings_class=TestSettings) + result = SettingsParameters.merge(base, None) + assert result is base + + @pytest.mark.unit + def test_merge_config_files_combines_and_deduplicates(self): + base = SettingsParameters.create( + settings_class=TestSettings, + config_files=["config1.yaml", "config2.yaml"] + ) + other = SettingsParameters.create( + settings_class=TestSettings, + config_files=["config2.yaml", "config3.yaml"] + ) + result = SettingsParameters.merge(base, other) + config_files_str = tuple(str(f) for f in result.config_files) + assert config_files_str == ("config1.yaml", "config2.yaml", "config3.yaml") + + @pytest.mark.unit + def test_merge_kwargs_second_wins(self): + base = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="base_value" + ) + other = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="other_value" + ) + result = SettingsParameters.merge(base, other) + assert result.kwargs["TEST_VAL_1"] == "other_value" + + @pytest.mark.unit + def test_merge_kwargs_combines(self): + base = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="base_value" + ) + other = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_2="other_value" + ) + result = SettingsParameters.merge(base, other) + assert result.kwargs["TEST_VAL_1"] == "base_value" + assert result.kwargs["TEST_VAL_2"] == "other_value" + + @pytest.mark.unit + def test_merge_env_prefix_second_wins(self): + base = SettingsParameters.create(settings_class=TestSettings, env_prefix="BASE_") + other = SettingsParameters.create(settings_class=TestSettings, env_prefix="OTHER_") + result = SettingsParameters.merge(base, other) + assert result.env_prefix == "OTHER_" + + @pytest.mark.unit + def test_merge_secrets_dir_second_wins(self): + base = SettingsParameters.create(settings_class=TestSettings, secrets_dir="/base") + other = SettingsParameters.create(settings_class=TestSettings, secrets_dir="/other") + result = SettingsParameters.merge(base, other) + assert result.secrets_dir == "/other" + + @pytest.mark.unit + def test_merge_incompatible_classes_raises_error(self): + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create(settings_class=MockBaseSettings) + with pytest.raises(ValueError, match="Settings class must match"): + SettingsParameters.merge(base, other) + + @pytest.mark.unit + def test_merge_same_class_succeeds(self): + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create(settings_class=TestSettings) + result = SettingsParameters.merge(base, other) + assert result.settings_class is TestSettings + + @pytest.mark.unit + def test_merge_prioritise_base(self): + base = SettingsParameters.create( + settings_class=TestSettings, + env_prefix="BASE_", + TEST_VAL_1="base_value" + ) + other = SettingsParameters.create( + settings_class=TestSettings, + env_prefix="OTHER_", + TEST_VAL_1="other_value" + ) + result = SettingsParameters.merge(base, other, prioritise_base=True) + assert result.env_prefix == "BASE_" + assert result.kwargs["TEST_VAL_1"] == "base_value" + + @pytest.mark.unit + def test_merge_both_none_kwargs(self): + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create(settings_class=TestSettings) + result = SettingsParameters.merge(base, other) + assert result.kwargs is None + + @pytest.mark.unit + def test_merge_both_none_config_files(self): + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create(settings_class=TestSettings) + result = SettingsParameters.merge(base, other) + assert result.config_files is None + + @pytest.mark.integration + def test_full_merge_workflow(self): + base = SettingsParameters.create( + settings_class=TestSettings, + config_files=["config1.yaml"], + env_prefix="BASE_", + TEST_VAL_1="base_value" + ) + other = SettingsParameters.create( + settings_class=TestSettings, + config_files=["config2.yaml"], + TEST_VAL_2="other_value" + ) + result = SettingsParameters.merge(base, other) + + config_files_str = set(str(f) for f in result.config_files) + assert config_files_str == {"config1.yaml", "config2.yaml"} + assert result.kwargs["TEST_VAL_1"] == "base_value" + assert result.kwargs["TEST_VAL_2"] == "other_value" + assert result.env_prefix == "BASE_" +``` + +- [ ] **Step 6: Run the full test suite** + +Run: `hatch run test:test -v` + +Expected: ALL PASS + +- [ ] **Step 7: Commit** + +```bash +git add -u +git commit -m "refactor: collapse merge framework into SettingsParameters.merge() + +Replace 10 merge participants (SettingsParameterMerger, FieldMergeUtils, +GenericMerger, MergePriority, get_merger, and 4 module-level functions) +with a single SettingsParameters.merge() classmethod. Same per-field +strategies preserved: combine config files, validate class compatibility, +last-wins for scalars, merge dicts for kwargs." +``` + +--- + +### Task 5: Remove SettingsUtils facade class + +**Files:** +- Modify: `src/mountainash_settings/settings_parameters/utils.py` +- Modify: `src/mountainash_settings/settings_parameters/__init__.py` +- Modify: `src/mountainash_settings/__init__.py` +- Modify: `src/mountainash_settings/settings/base_settings.py` +- Modify: `src/mountainash_settings/settings_cache/settings_functions.py` +- Modify: `src/mountainash_settings/settings_cache/settings_manager.py` +- Modify: `tests/test_settings_utils.py` + +- [ ] **Step 1: Identify remaining SettingsUtils usages** + +After Task 4, the remaining live `SettingsUtils` usages are: + +1. `base_settings.py:252` -- `SettingsUtils.format_kwargs_dict()` -> replace with `SettingsKwargsHandler.format_kwargs_dict()` +2. `base_settings.py:305` -- `SettingsUtils.format_config_file_list()` -> replace with `SettingsFileHandler.format_config_file_list()` +3. `base_settings.py:306` -- `SettingsUtils.format_kwargs_dict()` -> replace with `SettingsKwargsHandler.format_kwargs_dict()` +4. `settings_manager.py:105` -- `SettingsUtils.format_kwargs_dict()` -> replace with `SettingsKwargsHandler.format_kwargs_dict()` + +- [ ] **Step 2: Replace all SettingsUtils calls with direct handler calls** + +In `src/mountainash_settings/settings/base_settings.py`: + +Replace the import: +```python +from mountainash_settings.settings_parameters import SettingsFileHandler, SettingsParameters, SettingsUtils, SettingsFiles +``` +With: +```python +from mountainash_settings.settings_parameters import SettingsFileHandler, SettingsParameters, SettingsKwargsHandler, SettingsFiles +``` + +Replace line ~252: +```python +settings_dict = SettingsUtils.format_kwargs_dict(p_kwargs=settings_dict) +``` +With: +```python +settings_dict = SettingsKwargsHandler.format_kwargs_dict(p_kwargs=settings_dict) +``` + +Replace lines ~305-306: +```python +existing_config_files = SettingsUtils.format_config_file_list(config_files=config_files) +existing_kwargs = SettingsUtils.format_kwargs_dict(p_kwargs=self.SETTINGS_SOURCE_KWARGS) +``` +With: +```python +existing_config_files = SettingsFileHandler.format_config_file_list(config_files=config_files) +existing_kwargs = SettingsKwargsHandler.format_kwargs_dict(p_kwargs=self.SETTINGS_SOURCE_KWARGS) +``` + +In `src/mountainash_settings/settings_cache/settings_manager.py`: + +Replace the import: +```python +from ..settings_parameters import SettingsParameters, SettingsUtils +``` +With: +```python +from ..settings_parameters import SettingsParameters, SettingsKwargsHandler +``` + +Replace line ~105: +```python +settings_kwargs: Dict[str, Any]|None = SettingsUtils.format_kwargs_dict(p_kwargs=settings_parameters.kwargs) +``` +With: +```python +settings_kwargs: Dict[str, Any]|None = SettingsKwargsHandler.format_kwargs_dict(p_kwargs=settings_parameters.kwargs) +``` + +In `src/mountainash_settings/settings_cache/settings_functions.py`: + +Replace the import: +```python +from ..settings_parameters.utils import SettingsUtils, SettingsParameters +``` +With: +```python +from ..settings_parameters import SettingsParameters +``` + +(The `SettingsUtils.merge_settings_parameter_objects` call was already replaced in Task 4.) + +- [ ] **Step 3: Delete utils.py and remove from exports** + +Delete `src/mountainash_settings/settings_parameters/utils.py`. + +Update `src/mountainash_settings/settings_parameters/__init__.py`: + +```python +from .filehandler import SettingsFileHandler, SettingsFiles +from .kwargshandler import SettingsKwargsHandler +from .settings_parameters import SettingsParameters +from .merge_framework import ValidationError + +__all__ = [ + "SettingsParameters", + "SettingsFileHandler", + "SettingsKwargsHandler", + "SettingsFiles", + "ValidationError", +] +``` + +Update `src/mountainash_settings/__init__.py`: + +```python +from .__version__ import __version__ + +from .settings_parameters.settings_parameters import SettingsParameters +from .settings.base_settings import MountainAshBaseSettings +from .settings_cache.settings_functions import get_settings, get_settings_manager +from .settings_cache.settings_manager import SettingsManager + +__all__ = [ + "__version__", + + "SettingsParameters", + + "MountainAshBaseSettings", + "SettingsManager", + + "get_settings", + "get_settings_manager", +] +``` + +- [ ] **Step 4: Update or remove tests/test_settings_utils.py** + +Read the file first. If it only tests `SettingsUtils` delegation methods, delete it. If it has tests for behavior that moved, migrate those tests to the appropriate test file. + +- [ ] **Step 5: Run the full test suite** + +Run: `hatch run test:test -v` + +Expected: ALL PASS + +- [ ] **Step 6: Commit** + +```bash +git add -u +git commit -m "refactor: remove SettingsUtils facade class + +Replace all SettingsUtils method calls with direct calls to +SettingsFileHandler, SettingsKwargsHandler, and SettingsParameters.merge(). +SettingsUtils was a facade with only one-line delegations." +``` + +--- + +### Task 6: Normalize kwargs once at boundary + +**Files:** +- Modify: `src/mountainash_settings/settings_parameters/settings_parameters.py` +- Modify: `src/mountainash_settings/settings_parameters/kwargshandler.py` + +- [ ] **Step 1: Remove defensive kwargs unwrapping from SettingsParameters.merge()** + +In the `merge()` method added in Task 4, the line: +```python +merged_kwargs = merged_kwargs.get("kwargs", merged_kwargs) +``` +is defensive re-unwrapping. Remove it. The `create()` method already normalizes via `SettingsKwargsHandler.format_kwargs_dict()`, so by the time kwargs reach `merge()`, they're already clean. + +- [ ] **Step 2: Verify SettingsKwargsHandler.format_kwargs_dict() is the single normalization point** + +Confirm that `format_kwargs_dict()` in `kwargshandler.py` is called in `SettingsParameters.create()` and nowhere else needs the `.get("kwargs", ...)` unwrapping. If any test passes a raw `{"kwargs": {...}}` structure directly to a `SettingsParameters` constructor (bypassing `create()`), update the test to use `create()` instead. + +- [ ] **Step 3: Run the full test suite** + +Run: `hatch run test:test -v` + +Expected: ALL PASS + +- [ ] **Step 4: Commit** + +```bash +git add -u +git commit -m "cleanup: normalize kwargs once at SettingsParameters.create() boundary + +Remove defensive re-unwrapping of nested 'kwargs' key from merge logic. +Normalization now happens exactly once in create() via +SettingsKwargsHandler.format_kwargs_dict()." +``` + +--- + +### Task 7: Remove dead code + +**Files:** +- Modify: `src/mountainash_settings/settings_cache/settings_functions.py` +- Modify: `src/mountainash_settings/settings_cache/settings_manager.py` + +- [ ] **Step 1: Remove unreachable build_path_template** + +In `src/mountainash_settings/settings_cache/settings_functions.py`, delete lines 114-119 (the `build_path_template` function defined after the `return` statement inside `get_settings()`). + +- [ ] **Step 2: Remove commented-out code from settings_manager.py** + +In `src/mountainash_settings/settings_cache/settings_manager.py`, delete all the large commented-out method blocks (lines ~119-394). These are dead legacy code providing no value. + +- [ ] **Step 3: Remove commented-out code from settings_functions.py** + +In `src/mountainash_settings/settings_cache/settings_functions.py`, delete the commented-out `get_app_settings()` function at the bottom. + +- [ ] **Step 4: Run the full test suite** + +Run: `hatch run test:test -v` + +Expected: ALL PASS (no behavior changed) + +- [ ] **Step 5: Commit** + +```bash +git add -u +git commit -m "cleanup: remove dead code from settings_cache modules + +Remove unreachable build_path_template(), commented-out legacy methods +from SettingsManager, and commented-out get_app_settings()." +``` diff --git a/docs/superpowers/specs/2026-04-02-architecture-evaluation-design.md b/docs/superpowers/specs/2026-04-02-architecture-evaluation-design.md new file mode 100644 index 0000000..aa8823f --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-architecture-evaluation-design.md @@ -0,0 +1,154 @@ +# Architecture Evaluation: mountainash-settings + +**Date:** 2026-04-02 +**Scope:** Validate core design decisions across four architectural layers +**Method:** Design principles audit (correctness, predictability, simplicity) + +## Executive Summary + +mountainash-settings extends pydantic-settings with caching, multi-format config file support, parameter merging, and template resolution. The core architectural idea -- separating structural configuration identity from runtime overrides -- is genuinely well-conceived. The template system is clean and intuitive. However, the caching layer has a mutation bug, the merge framework is over-layered, the `namespace` field is dead weight, and `secrets_dir` is misclassified. + +## Layer-by-Layer Findings + +### Layer 1: Two-Tier Caching System + +**Components:** `@lru_cache` on `_get_settings()` + `SettingsManager.settings_object_cache` dict + +**Context:** Originally developed for multi-process systems (Dagster) where singleton preservation across process boundaries was unreliable. The redundancy was a rational defensive choice in that environment. + +#### Bug: Mutation of cached objects + +`SettingsManager.get_settings_object()` (`settings_manager.py:50`) calls `update_settings_from_dict()` directly on the cached instance when override kwargs are present. This mutates the shared cached object in-place, meaning subsequent callers receive an object polluted by a previous caller's runtime kwargs. + +The `apply_runtime_overrides()` path in `settings_functions.py:111` correctly calls `model_copy()` first. The two paths are inconsistent. + +**Impact:** Silent data corruption. Caller B gets Caller A's runtime overrides baked into their "cached" base settings. + +#### Redundancy + +`lru_cache` wraps the function that populates `settings_object_cache`. The `lru_cache` will always hit first on subsequent calls, making the dict lookup in `SettingsManager` unreachable after the first call per key. The two layers are keyed identically and serve the same purpose. + +**Note:** In the original Dagster context, `lru_cache` doesn't survive process forks (each worker gets its own), so both layers may have been doing real work in different processes. If multi-process support is still a requirement, this should be explicitly designed for rather than accidentally supported. + +#### Recommendations + +1. **P0 (Bug):** Fix the mutation in `SettingsManager.get_settings_object()` -- either copy before mutating, or remove the override logic from this path entirely and let `apply_runtime_overrides()` handle it exclusively +2. **P1:** Decide whether multi-process caching is a requirement. If yes, design explicitly (e.g., shared-memory cache, or accept per-process caches). If no, collapse to a single `lru_cache`-based approach and remove `SettingsManager` as a class +3. **P2:** If `SettingsManager` is retained, make the singleton pattern explicit rather than hidden behind `@lru_cache` on `get_settings_manager()` + +--- + +### Layer 2: Structural vs Runtime Parameter Split + +**Components:** `SettingsParameters` frozen dataclass with custom `__hash__`/`__eq__` + +#### Verdict: Strongest layer in the architecture + +The separation of structural parameters (cache identity) from runtime parameters (applied on retrieval) is a genuinely useful pattern that solves a real problem. The documentation on `__hash__` and `__eq__` is excellent. + +#### Bug: `secrets_dir` misclassified as runtime + +`secrets_dir` is excluded from the hash, but pydantic-settings reads configuration values from files in this directory during construction. Two parameter sets with the same structural params but different `secrets_dir` values would produce different field values, yet hash to the same cache key. The second caller's `secrets_dir` is silently ignored. + +#### Recommendations + +1. **P0 (Bug):** Move `secrets_dir` from runtime to structural -- include it in `__hash__` and `__eq__` +2. **P2:** Consider documenting the structural/runtime split as a first-class concept in the README, since it's the most distinctive architectural contribution of this package + +--- + +### Layer 3: The Merge Framework + +**Components:** `SettingsParameterMerger`, `FieldMergeUtils`, `SettingsUtils` (facade), `SettingsKwargsHandler`, `GenericMerger` (legacy), `MergePriority` (legacy), plus four module-level merge functions + +#### Over-layered + +10 participants for what is fundamentally: "given two `SettingsParameters`, produce a third by combining fields with a priority flag." The four module-level functions (`_merge_simple`, `_merge_config_files`, `_merge_kwargs`, `_merge_settings_class`) are clean and correct. Everything above them is indirection without added behavior. + +#### Duplicated kwargs unwrapping + +The `p_kwargs.get("kwargs", p_kwargs)` unwrapping pattern appears in both `_merge_kwargs` (`merge_framework.py:50`) and `SettingsKwargsHandler.format_kwargs_dict()` (`kwargshandler.py:26`). It's idempotent so it doesn't break, but it signals that the boundary between raw user input and normalized internal format isn't clearly drawn. + +#### Per-field strategies are well-chosen + +- Config files: combine and deduplicate (correct -- you want all files loaded) +- Settings class: validate compatibility, raise if different (correct -- mixing classes is a bug) +- Scalars (namespace, env_prefix): last-wins (correct -- override semantics) +- Kwargs: merge with second taking precedence (correct -- caller overrides base) + +#### Recommendations + +1. **P1:** Collapse to a single merge entry point. The four module-level functions are the right core. Wrap them in one `merge()` function or a single class method on `SettingsParameters` itself. Remove `SettingsParameterMerger`, `FieldMergeUtils`, `GenericMerger`, `MergePriority` +2. **P1:** Remove `SettingsUtils` as a facade class -- its methods are all one-line delegations. Move the merge entry point to `SettingsParameters.merge()` or a standalone function +3. **P2:** Normalize kwargs exactly once, at the boundary (`SettingsParameters.create()`), and trust internal code to receive clean data. Remove defensive re-unwrapping in merge functions + +--- + +### Layer 4: Template Resolution via `post_init` + +**Components:** `_build_template_mapping()`, `init_setting_from_template()`, `format_template_from_settings()` on `MountainAshBaseSettings` + +#### Verdict: Cleanest layer, no changes recommended + +Simple, predictable, well-proportioned. The `{placeholder}` syntax is immediately intuitive. The UPath integration for cross-platform path templates is a nice touch. Three methods, no unnecessary abstractions. + +#### Known constraint: ordering dependency + +Template resolution order depends on the order the subclass calls `init_setting_from_template()` in `post_init()`. If field A references field B which references field C, the author must resolve C -> B -> A. This is a reasonable trade-off -- automatic topological sorting would add complexity for a scenario that rarely arises. Worth documenting. + +#### Recommendations + +1. **P3:** Document the ordering constraint in the docstring of `post_init()` or in the README's template section +2. **No structural changes needed** + +--- + +### Cross-Cutting: The `namespace` Field + +**Components:** `SettingsParameters.namespace`, `MountainAshBaseSettings.SETTINGS_NAMESPACE`, `_init_namespace()`, merge logic in `FieldMergeUtils.merge_namespaces()` + +#### Dead weight with false affordance + +`namespace` participates in cache identity and is stored on the settings instance, but has zero behavioral effect. It doesn't influence config file selection, environment variable scoping, secret lookup, or any resolution logic. It's purely a cache discriminator. + +A user seeing `namespace="production"` would reasonably expect it to influence behavior. It doesn't. In the test suite, namespace values like `"test_init_file_prefix2"` reveal the actual use case: ensuring unique cache entries for test isolation. + +With the structural caching strategy working correctly, the difference between two settings instances should come from different config files, env_prefix, settings_class, or secrets_dir -- not an arbitrary label. + +#### Recommendations + +1. **P1:** Remove `namespace` from `SettingsParameters`, `MountainAshBaseSettings`, hash/eq logic, merge framework, and all related helpers (`_init_namespace`, `merge_namespaces`, `"DEFAULT"` fallback) +2. **P1:** Update tests to not rely on unique namespace strings for cache isolation. Tests should either use unique structural parameters or clear the cache between tests + +--- + +## Prioritized Recommendation Summary + +| Priority | Item | Layer | Type | +|----------|------|-------|------| +| **P0** | Fix cached object mutation in `SettingsManager.get_settings_object()` | Caching | Bug | +| **P0** | Move `secrets_dir` to structural parameters (include in hash/eq) | Parameters | Bug | +| **P1** | Remove `namespace` field entirely | Cross-cutting | Simplification | +| **P1** | Collapse merge framework to single entry point | Merge | Simplification | +| **P1** | Remove `SettingsUtils` facade class | Merge | Simplification | +| **P1** | Decide on single-process vs multi-process caching strategy | Caching | Architecture | +| **P2** | Normalize kwargs once at boundary, remove defensive re-unwrapping | Merge | Cleanup | +| **P2** | Document structural/runtime split as first-class concept | Parameters | Documentation | +| **P2** | Make singleton pattern explicit if `SettingsManager` is retained | Caching | Clarity | +| **P3** | Document template ordering constraint | Templates | Documentation | + +## Minor: Dead Code + +`build_path_template()` at `settings_functions.py:114` is defined inside `get_settings()` after the `return` statement. It's unreachable. Should be removed or moved to a utility module. + +## What to Preserve + +These are genuine strengths that should survive any refactoring: + +- **Structural vs runtime parameter split** -- the core caching insight +- **`SettingsParameters` as a frozen dataclass** -- immutable, hashable, well-documented +- **Per-field merge strategies** -- combine files, validate classes, last-wins for scalars +- **Template resolution via `post_init`** -- simple, intuitive, right-sized +- **Multi-format config file support** with `FileTypeRegistry` -- extensible, clean +- **The `create()` classmethod pattern** -- normalizes inputs before construction +- **UPath integration** for cross-platform path templates diff --git a/docs/superpowers/specs/2026-04-02-principles-document-design.md b/docs/superpowers/specs/2026-04-02-principles-document-design.md new file mode 100644 index 0000000..7e209a2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-principles-document-design.md @@ -0,0 +1,211 @@ +# Principles Document Design for mountainash-settings + +**Date:** 2026-04-02 +**Location:** `/home/nathanielramm/git/mountainash-io/mountainash/mountainash-central/01.principles/mountainash-settings/` +**Scope:** Create a principles directory following the established mountainash-expresions pattern + +## Goal + +Consolidate the validated architecture decisions, usage patterns, and development conventions for mountainash-settings into a browsable, authoritative reference. Primary audience: LLM coding agents who need to understand patterns and guardrails without analyzing the entire package. Secondary audiences: the maintainer returning after time away, and future contributors. + +## Structure + +``` +mountainash-settings/ + PRINCIPLES.md # Governance document + README.md # Index with status tables + a.architecture/ # 5 documents + structural-runtime-parameter-split.md + caching-strategy.md + template-resolution.md + multi-source-configuration.md + merge-strategy.md + b.usage/ # 4 documents + settings-creation-patterns.md + path-templating-with-upath.md + config-file-handling.md + post-init-patterns.md + c.development/ # 3 documents + testing-philosophy.md + code-style.md + versioning-and-releases.md +``` + +## Governance (PRINCIPLES.md) + +Adapted from mountainash-expresions governance with: +- Same document template (Status, The Principle, Rationale, Examples, Anti-Patterns, Technical Reference, Future Considerations) +- Same status markers (ENFORCED, ADOPTED, PROPOSED, EXPLORATORY) +- Three categories instead of seven (simpler package) +- Category precedence: a > b > c +- Added "Reading Order for Agents" section that directs agents to the right 2-3 documents based on their task + +## Category Mapping + +| Category | Concern | Role | +|----------|---------|------| +| **a** | Architecture | Structural foundations -- caching, parameters, merge, templates | +| **b** | Usage | Consumer-facing patterns -- how to create, configure, and use settings correctly | +| **c** | Development | Conventions for contributing -- testing, style, releases | + +## Document Inventory + +### a. Architecture (5 documents) + +| Document | Status | Summary | +|----------|--------|---------| +| `structural-runtime-parameter-split.md` | ENFORCED | Cache identity determined by structural params (config_files, settings_class, env_prefix, secrets_dir); kwargs are runtime overrides applied on retrieval | +| `caching-strategy.md` | ENFORCED | Two-tier caching (lru_cache + SettingsManager dict); cached instances never mutated; runtime overrides produce copies via model_copy() | +| `template-resolution.md` | ENFORCED | {placeholder} fields resolved in post_init() after all config sources loaded; resolution order is caller-controlled | +| `multi-source-configuration.md` | ENFORCED | Settings load from env vars, .env, YAML, TOML, JSON, secrets dirs; pydantic-settings field value priority governs precedence | +| `merge-strategy.md` | ENFORCED | SettingsParameters.merge() combines two parameter sets: config files deduplicated, classes validated compatible, scalars last-wins, kwargs merged dicts | + +### b. Usage (4 documents) + +| Document | Status | Summary | +|----------|--------|---------| +| `settings-creation-patterns.md` | ENFORCED | Three creation patterns: direct construction, SettingsParameters.create() + get_settings(), ClassMethod.get_settings(); when to use each | +| `path-templating-with-upath.md` | ENFORCED | Use UPath / operator for cross-platform path templates; never use PLATFORM_SLASH or f-strings with separators | +| `config-file-handling.md` | ENFORCED | FileTypeRegistry identifies by extension; dotfiles supported; files validated for existence at construction | +| `post-init-patterns.md` | ADOPTED | Override post_init() for template resolution and computed fields; resolve dependencies in topological order; use reinitialise flag | + +### c. Development (3 documents) + +| Document | Status | Summary | +|----------|--------|---------| +| `testing-philosophy.md` | ENFORCED | isolated_settings_manager fixture for cache isolation; never disable/skip tests; present failures to user | +| `code-style.md` | ENFORCED | Ruff formatting, Google-style docstrings, typing annotations, hatch environments, uv installer | +| `versioning-and-releases.md` | ADOPTED | CalVer YYYY.MM.MICRO; main for production, develop for RC; protected branches require code owner approval | + +## Document Content Summary + +### a.1 structural-runtime-parameter-split.md + +**The Principle:** SettingsParameters separates fields into structural (affect cache identity) and runtime (applied on retrieval). Structural: config_files, settings_class, env_prefix, secrets_dir. Runtime: kwargs only. Two parameter sets with identical structural params but different kwargs hash to the same value. + +**Key Anti-Patterns:** +- Adding new fields without deciding structural vs runtime +- Using arbitrary discriminator fields to force separate cache entries +- Treating secrets_dir as runtime (it's a config source) + +### a.2 caching-strategy.md + +**The Principle:** Two-tier caching (lru_cache + SettingsManager dict). Cached instances never mutated. model_copy() before update_settings_from_dict() when override kwargs present. No-override callers get original cached instance directly. + +**Key Anti-Patterns:** +- Calling update_settings_from_dict() on cached instance without copying +- Assuming object identity when kwargs differ +- Manipulating lru_cache directly in tests + +### a.3 template-resolution.md + +**The Principle:** {placeholder} fields resolved in post_init() via init_setting_from_template(). Formatter().parse() extracts referenced field names. Resolution order controlled by subclass call sequence. + +**Key Anti-Patterns:** +- Resolving templates in __init__ before config sources loaded +- Assuming automatic dependency ordering +- Using f-strings for templates (evaluate at definition time) + +### a.4 multi-source-configuration.md + +**The Principle:** Extends pydantic-settings with env, YAML, TOML, JSON, secrets simultaneously. Precedence: init > env > dotenv > YAML > TOML > JSON > file secrets. FileTypeRegistry identifies by extension including dotfiles. + +**Key Anti-Patterns:** +- Passing extensionless files +- Assuming YAML overrides env vars (env has higher priority) +- Relative paths without understanding expanduser() behavior + +### a.5 merge-strategy.md + +**The Principle:** SettingsParameters.merge(base, other) with per-field strategies: combine config files (deduplicate+sort), validate class compatibility, last-wins for scalars, merge dicts for kwargs. prioritise_base=True inverts. + +**Key Anti-Patterns:** +- Merging different settings_class values +- Expecting config file order preserved across merges +- Relying on merge to normalize kwargs format + +### b.1 settings-creation-patterns.md + +**The Principle:** Three patterns: (1) Direct construction for one-off/test, (2) SettingsParameters.create() + get_settings() for cached application use, (3) ClassMethod.get_settings() for subclass entry points. + +**Key Anti-Patterns:** +- get_settings() without settings_class +- SettingsParameters() constructor instead of create() (bypasses normalization) +- Re-creating settings in a loop instead of using cache + +### b.2 path-templating-with-upath.md + +**The Principle:** str(UPath("~") / "data" / "{ORG}" / "reports"). UPath handles platform separators. Placeholders survive string conversion. Resolved in post_init(). + +**Key Anti-Patterns:** +- PLATFORM_SLASH or os.sep (deprecated/removed) +- f-strings with path separators +- Backslash as UPath operator + +### b.3 config-file-handling.md + +**The Principle:** FileTypeRegistry identifies by extension (.env, .yaml/.yml, .toml, .json). Dotfiles supported. Files validated at construction (FileNotFoundError). Multiple same-type files deduplicated; later overrides earlier per pydantic-settings. + +**Key Anti-Patterns:** +- Files without recognized extensions (silently ignored) +- Assuming file order preserved across merges +- Relative paths without understanding expanduser() + +### b.4 post-init-patterns.md + +**The Principle:** Override post_init() for templates and computed fields. Call init_setting_from_template(template_str, current_value, reinitialise) per field. current_value prevents double-resolution. Canonical pattern: define TEMPLATE field, define target field defaulting to None, resolve in post_init(). + +**Key Anti-Patterns:** +- Forgetting super().post_init() +- Resolving dependent templates out of order +- Setting computed fields in __init__ instead of post_init() + +### c.1 testing-philosophy.md + +**The Principle:** isolated_settings_manager fixture for cache isolation. Distinct cache entries via different structural params (env_prefix, config_files), not arbitrary labels. Never disable/skip tests. Present failures, ask what to fix. + +**Key Anti-Patterns:** +- Unique string labels for cache isolation (old namespace pattern, removed) +- Global singleton in tests (cross-test pollution) +- Asserting object identity with kwargs present +- Auto-fixing tests without understanding + +### c.2 code-style.md + +**The Principle:** Ruff formatting/linting. Google docstrings. typing annotations. CamelCase classes, snake_case functions, UPPER_CASE constants. Hatch environments. uv installer. + +**Key Anti-Patterns:** +- pip instead of uv +- Adding docstrings/annotations to untouched code +- Error handling for impossible internal scenarios + +### c.3 versioning-and-releases.md + +**The Principle:** CalVer YYYY.MM.MICRO. develop for RC, main for production. feature/bugfix/hotfix branch strategy. Protected branches, code owner approval. CI: pytest, ruff, radon. SBOMs generated. + +**Key Anti-Patterns:** +- Pushing directly to main/develop +- Using semver +- Skipping CI checks + +## Agent Reading Order + +Included in PRINCIPLES.md governance doc: + +| Task | Read first | +|------|-----------| +| Modifying settings loading or caching | a.1 structural-runtime-parameter-split, a.2 caching-strategy | +| Adding a new settings class | b.1 settings-creation-patterns, b.4 post-init-patterns | +| Adding config file support | a.4 multi-source-configuration, b.3 config-file-handling | +| Working on path templates | b.2 path-templating-with-upath, a.3 template-resolution | +| Writing or fixing tests | c.1 testing-philosophy | +| Merging or combining parameters | a.5 merge-strategy, a.1 structural-runtime-parameter-split | +| General contribution | c.2 code-style, c.3 versioning-and-releases | + +## Source Material + +These principles are derived from: +- Architecture evaluation: `docs/superpowers/specs/2026-04-02-architecture-evaluation-design.md` +- Refactoring implementation: 7 commits on develop (ce4feb2..38eef0d) +- Existing CLAUDE.md project instructions +- Package README and existing documentation diff --git a/examples/path_templating_with_upath.py b/examples/path_templating_with_upath.py new file mode 100644 index 0000000..15c0522 --- /dev/null +++ b/examples/path_templating_with_upath.py @@ -0,0 +1,279 @@ +""" +Example: Path Templating with UPath + +This example demonstrates how to use UPath for cross-platform path templating +without needing PLATFORM_SLASH or other platform-specific hacks. + +Key Principles: +1. Use UPath's / operator to construct paths cross-platform +2. Convert to string for template storage (allows {placeholder} formatting) +3. Use MountainAshBaseSettings.init_setting_from_template() for resolution +4. Convert back to UPath after template formatting if needed +""" + +from pydantic import Field +from upath import UPath +from mountainash_settings import MountainAshBaseSettings + + +# Example 1: Basic Path Templates +# ================================ + +class PathTemplateSettings(MountainAshBaseSettings): + """Example showing path template patterns.""" + + # Organization info (used in templates) + ORGANISATION_NAME: str = Field(default="acme_corp") + PORTFOLIO_NAME: str = Field(default="production") + RUNDATE: str = Field(default="20250111") + + # Path templates using UPath - CORRECT PATTERN + # Build the path with UPath's / operator, then convert to string for template storage + REPORT_BASE_PATH_TEMPLATE: str = Field( + default=str( + UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "report" + ) + ) + + RESPONSE_BASE_PATH_TEMPLATE: str = Field( + default=str( + UPath("~") / "data" / "mountainash" / "{ORGANISATION_NAME}" / "{PORTFOLIO_NAME}" / "{RUNDATE}" / "response" + ) + ) + + # Resolved paths (set during post_init) + REPORT_BASE_PATH: str = Field(default=None) + RESPONSE_BASE_PATH: str = Field(default=None) + + # Derived path templates (reference other templates) + REPORT_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{REPORT_BASE_PATH}") / "report_data") + ) + + RESPONSE_DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("{RESPONSE_BASE_PATH}") / "response_data") + ) + + # Resolved derived paths + REPORT_DATA_PATH: str = Field(default=None) + RESPONSE_DATA_PATH: str = Field(default=None) + + def post_init(self, reinitialise: bool = False): + """Resolve all path templates.""" + super().post_init(reinitialise=reinitialise) + + # Resolve base paths + self.REPORT_BASE_PATH = self.init_setting_from_template( + template_str=self.REPORT_BASE_PATH_TEMPLATE, + current_value=self.REPORT_BASE_PATH, + reinitialise=reinitialise + ) + + self.RESPONSE_BASE_PATH = self.init_setting_from_template( + template_str=self.RESPONSE_BASE_PATH_TEMPLATE, + current_value=self.RESPONSE_BASE_PATH, + reinitialise=reinitialise + ) + + # Resolve derived paths (depend on base paths) + self.REPORT_DATA_PATH = self.init_setting_from_template( + template_str=self.REPORT_DATA_PATH_TEMPLATE, + current_value=self.REPORT_DATA_PATH, + reinitialise=reinitialise + ) + + self.RESPONSE_DATA_PATH = self.init_setting_from_template( + template_str=self.RESPONSE_DATA_PATH_TEMPLATE, + current_value=self.RESPONSE_DATA_PATH, + reinitialise=reinitialise + ) + + +# Example 2: Helper Function Pattern +# =================================== + +def build_path_template(*parts: str) -> str: + """ + Helper function to build path templates using UPath. + + Args: + *parts: Path components (can include template placeholders) + + Returns: + String path template suitable for Field(default=...) + + Example: + >>> build_path_template("~", "data", "{ORG}", "{DATE}", "reports") + '~/data/{ORG}/{DATE}/reports' + """ + path = UPath(parts[0]) + for part in parts[1:]: + path = path / part + return str(path) + + +class PathTemplateSettingsWithHelper(MountainAshBaseSettings): + """Example using helper function for cleaner code.""" + + ORGANISATION_NAME: str = Field(default="acme_corp") + PORTFOLIO_NAME: str = Field(default="production") + RUNDATE: str = Field(default="20250111") + + # Cleaner syntax with helper + REPORT_BASE_PATH_TEMPLATE: str = Field( + default=build_path_template( + "~", "data", "mountainash", + "{ORGANISATION_NAME}", "{PORTFOLIO_NAME}", "{RUNDATE}", "report" + ) + ) + + REPORT_BASE_PATH: str = Field(default=None) + + def post_init(self, reinitialise: bool = False): + super().post_init(reinitialise=reinitialise) + self.REPORT_BASE_PATH = self.init_setting_from_template( + template_str=self.REPORT_BASE_PATH_TEMPLATE, + current_value=self.REPORT_BASE_PATH, + reinitialise=reinitialise + ) + + +# Example 3: Working with UPath Objects After Resolution +# ======================================================= + +class PathTemplateSettingsWithUPathObjects(MountainAshBaseSettings): + """Example showing how to work with UPath objects after template resolution.""" + + ORGANISATION_NAME: str = Field(default="acme_corp") + RUNDATE: str = Field(default="20250111") + + DATA_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "{ORGANISATION_NAME}" / "{RUNDATE}") + ) + + DATA_PATH: str = Field(default=None) + + def post_init(self, reinitialise: bool = False): + super().post_init(reinitialise=reinitialise) + self.DATA_PATH = self.init_setting_from_template( + template_str=self.DATA_PATH_TEMPLATE, + current_value=self.DATA_PATH, + reinitialise=reinitialise + ) + + def get_data_path_as_upath(self) -> UPath: + """ + Get the resolved data path as a UPath object. + + Returns: + UPath object with expanduser() applied + """ + return UPath(self.DATA_PATH).expanduser() + + def ensure_data_path_exists(self) -> None: + """Ensure the data path exists, creating it if necessary.""" + path = self.get_data_path_as_upath() + path.mkdir(parents=True, exist_ok=True) + + +# Example 4: Migration from PLATFORM_SLASH +# ========================================= + +class LegacySettings(MountainAshBaseSettings): + """OLD WAY - Using PLATFORM_SLASH (DO NOT USE)""" + + from mountainash_utils_os import get_platform_slash + PLATFORM_SLASH: str = Field(default=get_platform_slash()) + + ORG_NAME: str = Field(default="acme") + + # OLD: Brittle, platform-dependent, hard to read + LEGACY_PATH_TEMPLATE: str = Field( + default=f"~{{PLATFORM_SLASH}}data{{PLATFORM_SLASH}}{{ORG_NAME}}{{PLATFORM_SLASH}}reports" + ) + + +class ModernSettings(MountainAshBaseSettings): + """NEW WAY - Using UPath (USE THIS)""" + + ORG_NAME: str = Field(default="acme") + + # NEW: Clean, cross-platform, readable + MODERN_PATH_TEMPLATE: str = Field( + default=str(UPath("~") / "data" / "{ORG_NAME}" / "reports") + ) + + +# Example Usage +# ============= + +if __name__ == "__main__": + print("Example 1: Basic Path Templates") + print("=" * 50) + settings1 = PathTemplateSettings() + print(f"REPORT_BASE_PATH_TEMPLATE: {settings1.REPORT_BASE_PATH_TEMPLATE}") + print(f"REPORT_BASE_PATH (resolved): {settings1.REPORT_BASE_PATH}") + print(f"REPORT_DATA_PATH (resolved): {settings1.REPORT_DATA_PATH}") + + print("\n\nExample 2: Helper Function Pattern") + print("=" * 50) + settings2 = PathTemplateSettingsWithHelper() + print(f"REPORT_BASE_PATH_TEMPLATE: {settings2.REPORT_BASE_PATH_TEMPLATE}") + print(f"REPORT_BASE_PATH (resolved): {settings2.REPORT_BASE_PATH}") + + print("\n\nExample 3: UPath Objects After Resolution") + print("=" * 50) + settings3 = PathTemplateSettingsWithUPathObjects() + print(f"DATA_PATH_TEMPLATE: {settings3.DATA_PATH_TEMPLATE}") + print(f"DATA_PATH (resolved): {settings3.DATA_PATH}") + print(f"As UPath object: {settings3.get_data_path_as_upath()}") + + print("\n\nExample 4: Migration Comparison") + print("=" * 50) + legacy = LegacySettings() + modern = ModernSettings() + print(f"Legacy template: {legacy.LEGACY_PATH_TEMPLATE}") + print(f"Modern template: {modern.MODERN_PATH_TEMPLATE}") + print("\nNotice: Modern version is cleaner and cross-platform!") + + +# Common Patterns and Best Practices +# =================================== + +""" +BEST PRACTICES: + +1. ALWAYS use UPath's / operator for path construction + ✓ GOOD: str(UPath("~") / "data" / "{ORG}") + ✗ BAD: f"~{PLATFORM_SLASH}data{PLATFORM_SLASH}{{ORG}}" + +2. Convert to string for template storage + ✓ GOOD: Field(default=str(UPath(...))) + ✗ BAD: Field(default=UPath(...)) # Can't format UPath directly + +3. Use double braces for template placeholders + ✓ GOOD: "{ORGANISATION_NAME}" + ✗ BAD: "{{ORGANISATION_NAME}}" # Only use double braces in f-strings + +4. Resolve templates in post_init() + ✓ GOOD: Use init_setting_from_template() in post_init() + ✗ BAD: Try to resolve during field definition + +5. Order matters for dependent templates + ✓ GOOD: Resolve base paths before derived paths + ✗ BAD: Reference unresolved template values + +6. Convert back to UPath for path operations + ✓ GOOD: UPath(resolved_path).mkdir(parents=True) + ✗ BAD: os.makedirs(resolved_path) # Use UPath for consistency + + +MIGRATION CHECKLIST: + +□ Replace PLATFORM_SLASH imports with UPath +□ Convert f"~{PLATFORM_SLASH}..." to str(UPath("~") / ...) +□ Remove get_platform_slash() dependency +□ Update template resolution order in post_init() +□ Test on both Windows and POSIX systems +□ Update tests to verify cross-platform behavior +""" diff --git a/hatch.toml b/hatch.toml index 01703dc..d3d69b5 100644 --- a/hatch.toml +++ b/hatch.toml @@ -15,13 +15,13 @@ installer = "uv" dependencies = [ "cyclonedx-bom==4.5.0", - "mountainash_constants @ {root:uri}/temp/mountainash-constants", - "mountainash_utils_os @ {root:uri}/temp/mountainash-utils-os", + # "mountainash_constants @ {root:uri}/temp/mountainash-constants", + # "mountainash_utils_os @ {root:uri}/temp/mountainash-utils-os", ] [envs.build_github.scripts] sbom-all = "cyclonedx-py environment > ./sbom-full.json" sbom-direct = "cyclonedx-py requirements > ./sbom-direct.json" -export-requirements = "hatch dep show requirements > ./requirements.txt" +export-requirements = "hatch dep show requirements > ./requirements.txt" #================ # Env: default @@ -52,8 +52,8 @@ dependencies = [ "pytest-check==2.5.3", "pytest-cov==6.1.1", - "mountainash_constants @ {root:uri}/temp/mountainash-constants", - "mountainash_utils_os @ {root:uri}/temp/mountainash-utils-os", + # "mountainash_constants @ {root:uri}/temp/mountainash-constants", + # "mountainash_utils_os @ {root:uri}/temp/mountainash-utils-os", ] [envs.test_github.scripts] test = "pytest" @@ -82,8 +82,8 @@ dependencies = [ "pytest-timeout>=2.1.0", # Test timing control "pytest-picked>=0.5.0", # Changed files testing - "mountainash_constants @ {root:uri}/../mountainash-constants", - "mountainash_utils_os @ {root:uri}/../mountainash-utils-os", + # "mountainash_constants @ {root:uri}/../mountainash-constants", + # "mountainash_utils_os @ {root:uri}/../mountainash-utils-os", ] diff --git a/src/mountainash_settings/__init__.py b/src/mountainash_settings/__init__.py index 87bbf50..dbad0ab 100644 --- a/src/mountainash_settings/__init__.py +++ b/src/mountainash_settings/__init__.py @@ -1,7 +1,6 @@ from .__version__ import __version__ from .settings_parameters.settings_parameters import SettingsParameters -from .settings_parameters.utils import SettingsUtils from .settings.base_settings import MountainAshBaseSettings from .settings_cache.settings_functions import get_settings, get_settings_manager from .settings_cache.settings_manager import SettingsManager @@ -10,7 +9,6 @@ "__version__", "SettingsParameters", - "SettingsUtils", "MountainAshBaseSettings", "SettingsManager", 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 diff --git a/src/mountainash_settings/settings/app/app_settings.py b/src/mountainash_settings/settings/app/app_settings.py index 561b3cf..ae0ddb3 100644 --- a/src/mountainash_settings/settings/app/app_settings.py +++ b/src/mountainash_settings/settings/app/app_settings.py @@ -4,10 +4,10 @@ from pydantic import Field from upath import UPath -from mountainash_utils_os import get_platform_slash +# from mountainash_utils_os import get_platform_slash from mountainash_settings import MountainAshBaseSettings, SettingsParameters -from .app_settings_templates import get_app_settings_templates +from .app_settings_templates import AppSettingsTemplates """AppSettings class. @@ -25,15 +25,18 @@ class AppSettings(MountainAshBaseSettings): def __init__(self, config_files: Optional[str|UPath|List[str|UPath]|Tuple[str|UPath]] = None, settings_parameters: Optional[SettingsParameters] = None, + template_settings_parameters: Optional[SettingsParameters] = None, + **kwargs) -> None: super().__init__(config_files=config_files, settings_parameters=settings_parameters, + template_settings_parameters=template_settings_parameters, **kwargs) # General App Settings - PLATFORM_SLASH: str = Field(default=get_platform_slash()) + # PLATFORM_SLASH: str = Field(default=get_platform_slash()) LOCALE_TIMEZONE: str = Field(default="UTC") DEBUG: bool = Field(default=False) @@ -43,7 +46,10 @@ def __init__(self, - def post_init(self, reinitialise: bool = False): + def post_init(self, + template_settings_parameters: Optional[SettingsParameters] = None, + reinitialise: Optional[bool] = False + ): """Initializes dynamic settings from template strings. This method sets attribute values that need to be dynamically @@ -71,5 +77,17 @@ def post_init(self, reinitialise: bool = False): settings.post_init() # Dynamically initialize settings """ super().post_init(reinitialise=reinitialise) + app_settings_templates = self._init_template_object(template_settings_parameters) + + self.RUNDATETIME = self.init_setting_from_template(template_str=app_settings_templates.RUNDATETIME_TEMPLATE, current_value=self.RUNDATETIME, reinitialise=reinitialise) + + def _init_template_object(self, template_settings_parameters) -> AppSettingsTemplates: + + template_class = template_settings_parameters.settings_class if template_settings_parameters is not None else None + + if template_class is not None and issubclass(template_class, AppSettingsTemplates): + app_settings_templates = template_class.get_settings(template_settings_parameters) + else: + app_settings_templates = AppSettingsTemplates.get_settings() - self.RUNDATETIME = self.init_setting_from_template(template_str=get_app_settings_templates().RUNDATETIME_TEMPLATE, current_value=self.RUNDATETIME, reinitialise=reinitialise) + return app_settings_templates diff --git a/src/mountainash_settings/settings/app/app_settings_templates.py b/src/mountainash_settings/settings/app/app_settings_templates.py index 4279cb9..fa4c151 100644 --- a/src/mountainash_settings/settings/app/app_settings_templates.py +++ b/src/mountainash_settings/settings/app/app_settings_templates.py @@ -1,19 +1,26 @@ +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 +class AppSettingsTemplates(MountainAshBaseSettings): -class AppSettingsTemplates(BaseSettings): + def __init__(self, + config_files: Optional[str|UPath|List[str|UPath]|Tuple[str|UPath]] = None, + settings_parameters: Optional[SettingsParameters] = None, + **kwargs) -> None: - model_config = SettingsConfigDict( - extra="ignore", - ) + + super().__init__(config_files=config_files, + settings_parameters=settings_parameters, + **kwargs) RUNDATETIME_TEMPLATE: str = Field(default="{RUNDATE}T{RUNTIME}") #This is here to avoid a circular import. Would otherwise be in app_settings_functions @lru_cache(maxsize=None) -def get_app_settings_templates() -> AppSettingsTemplates: +def get_default_app_settings_templates() -> AppSettingsTemplates: """ Retrieves the AppSettings object for a given namespace. @@ -23,4 +30,4 @@ def get_app_settings_templates() -> AppSettingsTemplates: Returns: AppSettings: The AppSettings object for the given namespace. """ - return AppSettingsTemplates() \ No newline at end of file + return AppSettingsTemplates() diff --git a/src/mountainash_settings/settings/base_settings.py b/src/mountainash_settings/settings/base_settings.py index c0750f0..a10c373 100644 --- a/src/mountainash_settings/settings/base_settings.py +++ b/src/mountainash_settings/settings/base_settings.py @@ -6,7 +6,7 @@ from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict, PydanticBaseSettingsSource, TomlConfigSettingsSource, YamlConfigSettingsSource, JsonConfigSettingsSource -from mountainash_settings.settings_parameters import SettingsFileHandler, SettingsParameters, SettingsUtils, SettingsFiles +from mountainash_settings.settings_parameters import SettingsFileHandler, SettingsParameters, SettingsKwargsHandler, SettingsFiles # T = TypeVar('T', bound='BaseSettings') T = TypeVar('T', BaseSettings, 'MountainAshBaseSettings') @@ -23,7 +23,6 @@ class MountainAshBaseSettings(BaseSettings): ) #Tracablility and repeatability - SETTINGS_NAMESPACE: str = Field(default=None) SETTINGS_CLASS: Type = Field(default=None) SETTINGS_CLASS_NAME: str = Field(default=None) @@ -43,6 +42,7 @@ class MountainAshBaseSettings(BaseSettings): def __init__(self, config_files: Optional[str|UPath|List[str|UPath]|Tuple[str|UPath]] = None, settings_parameters: Optional[SettingsParameters] = None, + template_settings_parameters: Optional[SettingsParameters] = None, **kwargs) -> None: @@ -54,7 +54,7 @@ def __init__(self, ) if settings_parameters is not None: - local_settings_params = SettingsUtils.merge_settings_parameter_objects(settings_parameters, local_settings_params) + local_settings_params = SettingsParameters.merge(settings_parameters, local_settings_params) obj_config_files: SettingsFiles = SettingsFileHandler.separate_config_files(local_settings_params.config_files) @@ -97,7 +97,6 @@ def __init__(self, #Update all vals from valid kwargs self.update_settings_from_dict(settings_dict=valid_attribute_kwargs) - setattr(self, "SETTINGS_NAMESPACE", local_settings_params.namespace) setattr(self, "SETTINGS_CLASS", local_settings_params.settings_class or MountainAshBaseSettings) setattr(self, "SETTINGS_CLASS_NAME", local_settings_params.settings_class.__name__ if local_settings_params.settings_class else "MountainAshBaseSettings") setattr(self, "SETTINGS_SOURCE_ENV_PREFIX", local_settings_params.env_prefix) @@ -134,7 +133,6 @@ def settings_customise_sources( def get_settings(cls, settings_parameters: Optional[SettingsParameters] = None, settings_class: Optional[Type[T]] = None, - settings_namespace: Optional[str] = None, config_files: Optional[Union[UPath, str, List[UPath|str]]] = None, env_prefix: Optional[str] = None, **kwargs @@ -152,7 +150,6 @@ def get_settings(cls, settings_instance: Any = get_settings( settings_parameters = settings_parameters, settings_class = settings_class, - settings_namespace = settings_namespace, config_files = config_files, env_prefix=env_prefix, **kwargs @@ -173,8 +170,7 @@ def __hash__(self) -> int: """ - return hash((self.SETTINGS_NAMESPACE, - self.SETTINGS_CLASS_NAME, + return hash((self.SETTINGS_CLASS_NAME, tuple(self.SETTINGS_SOURCE_ENV_FILES) if self.SETTINGS_SOURCE_ENV_FILES else None, tuple(self.SETTINGS_SOURCE_ENV_PREFIX) if self.SETTINGS_SOURCE_ENV_PREFIX else None, tuple(self.SETTINGS_SOURCE_YAML_FILES) if self.SETTINGS_SOURCE_YAML_FILES else None, @@ -195,7 +191,7 @@ def _build_template_mapping(self, template_str: str) -> Dict[str, Any]: raise AttributeError(f"The object does not have an attribute named '{field_name}'") return mapping - def init_setting_from_template(self, template_str:str, current_value: Optional[str] = None, reinitialise: bool = False): + def init_setting_from_template(self, template_str:str, current_value: Optional[str] = None, reinitialise: Optional[bool] = False): """Initializes a setting value from a template string, replacing placeholders with values from the settings object. @@ -248,7 +244,7 @@ def update_settings_from_dict(self, settings_dict: Optional[dict[str, Any]]) -> settings_dict: The dictionary of settings to update. """ - settings_dict = SettingsUtils.format_kwargs_dict(p_kwargs=settings_dict) + settings_dict = SettingsKwargsHandler.format_kwargs_dict(p_kwargs=settings_dict) if settings_dict is None: return None @@ -261,7 +257,10 @@ def update_settings_from_dict(self, settings_dict: Optional[dict[str, Any]]) -> setattr(self, 'SETTINGS_SOURCE_KWARGS', settings_dict) - def post_init(self, reinitialise: bool = False) -> None: + def post_init(self, + template_settings_parameters: Optional[SettingsParameters] = None, + reinitialise: Optional[bool] = False + ) -> None: """ Hook for post-initialization processing. @@ -297,14 +296,12 @@ def extract_settings_parameters(self) -> SettingsParameters: config_files += self.SETTINGS_SOURCE_JSON_FILES - existing_namespace = self.SETTINGS_NAMESPACE or None - existing_config_files = SettingsUtils.format_config_file_list(config_files=config_files) - existing_kwargs = SettingsUtils.format_kwargs_dict(p_kwargs=self.SETTINGS_SOURCE_KWARGS) + existing_config_files = SettingsFileHandler.format_config_file_list(config_files=config_files) + existing_kwargs = SettingsKwargsHandler.format_kwargs_dict(p_kwargs=self.SETTINGS_SOURCE_KWARGS) existing_settings_class = self.SETTINGS_CLASS or None existing_env_prefix = self.SETTINGS_SOURCE_ENV_PREFIX or None params: SettingsParameters = SettingsParameters.create( - namespace= existing_namespace, settings_class= existing_settings_class, config_files= existing_config_files, kwargs= existing_kwargs, diff --git a/src/mountainash_settings/settings_cache/settings_functions.py b/src/mountainash_settings/settings_cache/settings_functions.py index 6c707e8..8c5a066 100644 --- a/src/mountainash_settings/settings_cache/settings_functions.py +++ b/src/mountainash_settings/settings_cache/settings_functions.py @@ -4,101 +4,76 @@ from pydantic_settings import BaseSettings from upath import UPath -from ..settings_parameters.utils import SettingsUtils, SettingsParameters +from ..settings_parameters.settings_parameters import SettingsParameters from .settings_manager import SettingsManager from ..settings import MountainAshBaseSettings -# from mountainash_settings.app.app_settings import AppSettings @lru_cache(maxsize=None) -def get_settings_manager( - # settings_class: Optional[Type[BaseSettings]] = None - ) -> SettingsManager: +def get_settings_manager() -> SettingsManager: """ - Retrieves the SettingsManager instance. + Retrieves the SettingsManager singleton instance. Returns: - SettingsManager: The singleton instance of SettingsManager - per settings_class + SettingsManager: The singleton instance of SettingsManager """ - - - return SettingsManager( - # settings_class=settings_class - ) + return SettingsManager() @lru_cache(maxsize=None) -def _get_settings(settings_parameters: SettingsParameters, - #settings_class: Optional[Type[BaseSettings]] = BaseSettings, - ) -> MountainAshBaseSettings: +def _get_settings(settings_parameters: SettingsParameters) -> MountainAshBaseSettings: """ - Retrieves the AppSettings object for a given namespace. + Retrieves or creates a settings object for the given parameters. + + Uses lru_cache for efficient caching based on SettingsParameters hash. Args: - namespace (str): The namespace for the configuration. + settings_parameters: The structural parameters identifying the settings. Returns: - AppSettings: The AppSettings object for the given namespace. + MountainAshBaseSettings: The cached or newly created settings object. """ - objSettingsManager: SettingsManager = get_settings_manager() - settings: MountainAshBaseSettings = objSettingsManager.get_or_create_settings(settings_parameters=settings_parameters) + settings: MountainAshBaseSettings = objSettingsManager.get_or_create_settings(settings_parameters=settings_parameters) return settings - -def get_settings( settings_parameters: Optional[SettingsParameters] = None, - settings_class: Optional[Type[MountainAshBaseSettings]] = None, - settings_namespace: Optional[str] = None, - config_files: Optional[Union[UPath, str, List[UPath|str]]] = None, - env_prefix: Optional[str] = None, - **kwargs - ) -> BaseSettings: +def get_settings(settings_parameters: Optional[SettingsParameters] = None, + settings_class: Optional[Type[MountainAshBaseSettings]] = None, + config_files: Optional[Union[UPath, str, List[UPath|str]]] = None, + env_prefix: Optional[str] = None, + **kwargs + ) -> BaseSettings: """ - The main function to be called to retrieve the application settings for a given namespace. - This function is exported from the module! + The main function to retrieve application settings. Args: - settings_parameters (SettingsParameters): The settings parameters for the settings object. - settings_class (Type[MountainAshBaseSettings]): The class of the settings object to be retrieved. - settings_namespace (str, optional): The namespace for the configuration. Defaults to None, which retrieves the default namespace. - config_files (Optional[Union[UPath, str, List[UPath|str]]]): The configuration files that the settings object will use to load settings. - kwargs (Dict[Any,Any]): Additional keyword arguments that will be passed to the settings object. + settings_parameters: Pre-built settings parameters object. + settings_class: The class of settings to create. + config_files: Configuration files to load. + env_prefix: Environment variable prefix. + **kwargs: Additional keyword arguments passed as runtime overrides. Returns: - AppSettings: The AppSettings object for the given namespace. + BaseSettings: The settings instance. """ - - # We will need to be clever and careful here. - # It makes sense to separate initialisation vs getting of settings. - # Getting a non-initialised should throw a warning, but not halt play! - # Initialisation should be done once ( *per thread/process!), and then the settings retrieved. If re-initing and existing, an error should be thrown. - # getting, however should be by namespace, with validation. - - #Is it possible to retrieve an existing settings, and then augment with kwargs, just for this instance? - #We should remain as close to the priority described here as possible: https://docs.pydantic.dev/latest/concepts/pydantic_settings/#field-value-priority - if settings_parameters: if not isinstance(settings_parameters, SettingsParameters): raise ValueError("The settings_parameters parameter must be an instance of SettingsParameters.") local_settings_parameters = SettingsParameters.create( settings_class=settings_class, - namespace=settings_namespace, config_files=config_files, env_prefix=env_prefix, **kwargs ) - - final_settings_parameters = SettingsUtils.merge_settings_parameter_objects(settings_parameters, local_settings_parameters) + final_settings_parameters = SettingsParameters.merge(settings_parameters, local_settings_parameters) else: - final_settings_parameters = SettingsParameters.create( settings_class=settings_class, - namespace=settings_namespace, config_files=config_files, env_prefix=env_prefix, **kwargs @@ -109,41 +84,3 @@ def get_settings( settings_parameters: Optional[SettingsParameters] = None, # Apply runtime overrides to the cached instance return final_settings_parameters.apply_runtime_overrides(cached_settings) - - -# def get_app_settings( settings_parameters: SettingsParameters, -# settings_namespace: Optional[str] = None, -# config_files: Optional[Union[UPath, str, List[UPath|str]]] = None, -# env_prefix: Optional[str] = None, -# **kwargs -# ) -> AppSettings: - -# """ -# The main function to be called to retrieve the application settings for a given namespace. - - -# Args: -# settings_namespace (str, optional): The namespace for the configuration. Defaults to None, which retrieves the default namespace. -# config_files (Optional[Union[UPath, str, List[UPath|str]]]): The configuration files that the settings object will use to load settings. -# kwargs (Dict[Any,Any]): Additional keyword arguments that will be passed to the settings object. - -# Returns: -# AppSettings: The AppSettings object for the given namespace. - -# Raises: -# ValueError: If the settings object retrieved is not of type AppSettings. -# """ - -# settings_class = AppSettings - -# auth_settings: MountainAshBaseSettings = get_settings(settings_parameters=settings_parameters, -# settings_class=settings_class, -# settings_namespace=settings_namespace, -# config_files=config_files, -# env_prefix=env_prefix -# **kwargs) - -# if isinstance(auth_settings, AppSettings): -# return auth_settings -# else: -# raise ValueError("The settings object retrieved is not of type AppSettings.") diff --git a/src/mountainash_settings/settings_cache/settings_manager.py b/src/mountainash_settings/settings_cache/settings_manager.py index b078aab..a3dbc83 100644 --- a/src/mountainash_settings/settings_cache/settings_manager.py +++ b/src/mountainash_settings/settings_cache/settings_manager.py @@ -1,393 +1,97 @@ from typing import Optional, Any, Type, Dict from importlib import import_module -from pydantic_settings import BaseSettings -from ..settings_parameters import SettingsParameters, SettingsUtils +from ..settings_parameters import SettingsParameters, SettingsKwargsHandler from ..settings import MountainAshBaseSettings class SettingsManager: """ A manager class for handling multiple instances of application settings. - Attributes: - settings_object_cache (dict): A dictionary to store AppSettings objects with their namespaces. - protected_attributes (list): A list of attributes that are protected from being overwritten. - reserved_kwargs (set): A set of reserved keyword arguments that are not allowed to be passed to the settings object. - # auth_parameters (SettingsParameters): The parameters needed to create an authentication settings object. - + Maintains a cache of settings objects keyed by SettingsParameters. + When runtime override kwargs are present, returns a copy with overrides + applied -- the cached instance is never mutated. """ - # protected_attributes: List[str] = ['BATCH_TIER', 'BATCH_VERSION'] - # reserved_kwargs = {"_env_file","_env_file_encoding", "_env_prefix"} - - # auth_parameters: Optional[SettingsParameters] = None - # settings_object_cache: dict[Any, BaseSettings] = {} - - def __init__(self - ) -> None: - + def __init__(self) -> None: self.settings_object_cache: Dict[Any, MountainAshBaseSettings] = {} - # @classmethod def get_settings_object(self, settings_parameters: SettingsParameters) -> MountainAshBaseSettings: """ - Gets the configuration object for a given namespace. + Gets the configuration object for a given set of parameters. + + If the parameters contain runtime override kwargs, returns a copy + with overrides applied. The cached instance is never mutated. + Args: - settings_namespace (str): The namespace for the configuration. + settings_parameters: The parameters for the configuration. Returns: - BaseSettings: The configuration object for the given namespace. + MountainAshBaseSettings: The configuration object for the given parameters. Raises: - ValueError: If the configuration object is is not an BaseSettings object. + ValueError: If the configuration object is not a MountainAshBaseSettings object. """ - obj_settings: Optional[BaseSettings] = self.settings_object_cache.get(settings_parameters, None) + obj_settings: Optional[MountainAshBaseSettings] = self.settings_object_cache.get(settings_parameters, None) - override_kwargs = settings_parameters.get_attribute_settings_kwargs() + if not isinstance(obj_settings, MountainAshBaseSettings): + raise ValueError( + f"Configuration for '{settings_parameters}' found, but is not a " + f"MountainAshBaseSettings object. Received a {type(obj_settings)}" + ) + override_kwargs = settings_parameters.get_attribute_settings_kwargs() if override_kwargs: + obj_settings = obj_settings.model_copy() obj_settings.update_settings_from_dict(settings_dict=override_kwargs) - if isinstance(obj_settings, MountainAshBaseSettings): - return obj_settings - else: - raise ValueError(f"Configuration for namespace '{settings_parameters}' found, but is not an MountainAshBaseSettings object. Received a {type(obj_settings)}") + return obj_settings - # @classmethod - def is_namespace_initialised(self, settings_parameters: SettingsParameters) -> bool: + def is_initialised(self, settings_parameters: SettingsParameters) -> bool: """ - Checks if the namespace is already initialised. + Checks if the settings parameters are already initialised in the cache. + Args: - settings_namespace (str): The namespace for the configuration. + settings_parameters: The parameters for the configuration. Returns: - bool: True if the namespace is already initialised, False otherwise. - Raises: - ValueError: If the namespace is not found in the settings_object_cache dictionary. + bool: True if already initialised, False otherwise. """ - - #check if the namespace is already initialised by looking at the keys in the settings_object_cache dict return settings_parameters in self.settings_object_cache - # @classmethod def get_or_create_settings(self, settings_parameters: SettingsParameters) -> MountainAshBaseSettings: """ - Initializes the settings for a given set of parameters. + Gets existing or creates new settings for a given set of parameters. Args: - settings_parameters (SettingsParameters): The settings for the configuration. + settings_parameters: The settings parameters for the configuration. + Returns: + MountainAshBaseSettings: The settings object. + Raises: + ValueError: If settings_class is not provided. """ - - #Check if the namespace is already initialised - if self.is_namespace_initialised(settings_parameters=settings_parameters): - #Get the existing settings object + if self.is_initialised(settings_parameters=settings_parameters): return self.get_settings_object(settings_parameters=settings_parameters) - #Otherwise We have a new config to create else: - if not settings_parameters.settings_class: raise ValueError("settings_parameters.settings_class cannot be empty.") - # #Create the Settings object class_module = settings_parameters.settings_class.__module__ class_name = settings_parameters.settings_class.__name__ settings_class_ref: Type[MountainAshBaseSettings] = getattr(import_module(name=class_module), class_name) if issubclass(settings_class_ref, MountainAshBaseSettings): - obj_settings = settings_class_ref(settings_parameters = settings_parameters) - + obj_settings = settings_class_ref(settings_parameters=settings_parameters) else: - - settings_kwargs: Dict[str, Any]|None = SettingsUtils.format_kwargs_dict(p_kwargs=settings_parameters.kwargs) - #Create the settings object with no settings_parameters, but kwargs if they are provided + settings_kwargs: Dict[str, Any]|None = SettingsKwargsHandler.format_kwargs_dict(p_kwargs=settings_parameters.kwargs) if settings_kwargs: obj_settings = settings_class_ref(**settings_kwargs) else: obj_settings = settings_class_ref() - # if not isinstance(obj_settings, BaseSettings): - # raise ValueError(f"Configuration for namespace '{settings_parameters.namespace}' found, but obj_settings is not an BaseSettings object. It is of type {type(obj_settings)}") - self.settings_object_cache[settings_parameters] = obj_settings return obj_settings - - - # def get_settings(self, - # settings_parameters: SettingsParameters, - - # # settings_namespace: str, - # # settings_class: Optional[Type[BaseSettings]] = BaseSettings, - # # config_files: Optional[Union[UPath, str, List[UPath|str], Tuple[UPath|str]]] = None, - # # **kwargs - - # ) -> BaseSettings: - - # """ - - # Gets the configuration object for a given namespace. If the namespace is not initialised, it will create a new configuration object. - - # Args: - # settings_namespace (str): The namespace for the configuration. - # settings_class (Type[BaseSettings]): The settings class to be used. - # config_files (Union[UPath, List[UPath]]): The configuration file or list of configuration files. - # kwargs (Dict[str, Any]): The keyword arguments to be combined. - - # Returns: - # BaseSettings: The configuration object for the given namespace. - - # Raises: - # ValueError: If the settings_class is empty. - - # """ - - # # First step is the namespace only - - # # Check if the namespace is already initialised - # if self.is_namespace_initialised(settings_parameters=settings_parameters): - - # # Get the existing settings object - # obj_settings: BaseSettings = self.get_settings_object(settings_parameters=settings_parameters) - - # else: - # # Create a new one - # obj_settings = self.init_settings(settings_parameters=settings_parameters) - - # if not isinstance(obj_settings, BaseSettings): - # raise ValueError(f"Configuration for namespace '{settings_parameters.namespace}' not found.") - - # return obj_settings - - - # # @classmethod - # def get_existing_settings(self, - # settings_parameters: SettingsParameters, - # # settings_namespace: str, - # # #config_files: Optional[Union[UPath, str, List[UPath|str], Tuple[UPath|str]]] = None, - # # **kwargs - # ) -> BaseSettings: - # """ - # Gets the existing configuration object for a given namespace. - # Args: - # settings_namespace (str): The namespace for the configuration. - # kwargs (Dict[str, Any]): The keyword arguments to be combined. - # Returns: - # BaseSettings: The configuration object for the given namespace. - # """ - - # print(f"Getting existing config via get_existing_config(): {settings_namespace}") - - # # Get the existing settings object - # obj_settings: BaseSettings = self.get_config_object(settings_namespace=settings_namespace) - # settings_class: Type = obj_settings.SETTINGS_CLASS - - # # Overwrite the settings with valid runtime kwargs - # new_kwargs: Dict[Any, Any] | None = SettingsUtils.get_valid_setting_kwargs(p_kwargs=kwargs, settings_class=settings_class) - # merged_kwargs: Dict[str, Any] | None = SettingsUtils.resolve_kwargs(new_kwargs=new_kwargs, - # original_kwargs=obj_settings.SETTINGS_SOURCE_KWARGS) - - # #Is this correct? - # if merged_kwargs and merged_kwargs != obj_settings.SETTINGS_SOURCE_KWARGS: - # print(f"Creating a copy of settings for namespace '{settings_namespace}' with kwargs: {merged_kwargs}. Original kwargs {obj_settings.SETTINGS_SOURCE_KWARGS}") - # #This is a localised update with kwargs. Not a change to the original - # obj_settings = obj_settings.model_copy() - # obj_settings.update_settings_from_dict(settings_dict=merged_kwargs) - - # return obj_settings - - # # @classmethod - # def get_new_config(self, - # settings_namespace: str, - # settings_class: Type[BaseSettings], - # config_files: Optional[Union[UPath, str, List[UPath|str], Tuple[UPath|str]]] = None, - # **kwargs) -> BaseSettings: - - # """ - # Creates a new configuration object for a given namespace. - - # Args: - # settings_namespace (str): The namespace for the configuration. - # settings_class (Type[BaseSettings]): The settings class to be used. - # config_files (Union[UPath, List[UPath]]): The configuration file or list of configuration files. - # kwargs (Dict[str, Any]): The keyword arguments to be combined. - - # Returns: - # BaseSettings: The configuration object for the given namespace. - - # """ - - - # print(f"Initialising new config via get_new_config(): {settings_namespace}") - - # obj_settings: BaseSettings = self.init_config(settings_namespace=settings_namespace, - # settings_class=settings_class, - # config_files=config_files, **kwargs) - - # if isinstance(obj_settings, BaseSettings): - # return obj_settings - - - - - - - # # @classmethod - # def validate_kwargs_keys(self, - # settings_class: Type[BaseSettings], - # kwargs: Optional[Dict[str, Any]]=None, - # ) -> None: - # """ - # Combines multiple dictionaries or sets and checks if a comparison dictionary or set - # has elements not present in the combined inputs. Returns a set of unique elements. - - # Args: - # settings_class (Type[BaseSettings]): The settings class to be used. - # kwargs (Dict[str, Any]): The keyword arguments to be combined. - - # Raises: - # ValueError: If the comparison dictionary has elements not present in the combined inputs. - # """ - # # Build a set of all keys/elements from the inputs to be combined - - # if kwargs: - # combined_elements: set = self.reserved_kwargs - - # valid_setting_kwargs = SettingsUtils.get_valid_setting_kwargs(p_kwargs=kwargs, settings_class=settings_class) - # if valid_setting_kwargs: - # combined_elements.update(valid_setting_kwargs.keys()) - - # # Create a set of keys from the kwargs dictionary - # kwargs_elements = set(kwargs.keys()) - - # # Find the unique elements in the comparison input - # unique_elements = kwargs_elements - combined_elements - - # if len(unique_elements) > 0: - # raise ValueError(f"Invalid kwargs provided: {unique_elements}") - - - - # # @classmethod - # def validate_init_existing_namespace(self, - # settings_namespace: str, - # config_files: Optional[Union[UPath, str, List[UPath|str], Tuple[UPath|str]]] = None, - # env_prefix: Optional[str] = None, - # **kwargs) -> None: - # """ - - # Validates that the namespace is already initialised and that the parameters have not changed. - - # Args: - # settings_namespace (str): The namespace for the configuration. - # config_files (Union[UPath, List[UPath]]): The configuration file or list of configuration files. - # kwargs (Dict[str, Any]): The keyword arguments to be combined. - # Raises: - # ValueError: If the namespace is already initialised and the parameters have changed. - # """ - - - # #This will raise an error if not found - # obj_settings: BaseSettings = self.get_config_object(settings_namespace=settings_namespace) - - # existing_config_files = SettingsUtils.format_config_file_list(config_files=obj_settings.SETTINGS_SOURCE_ENV_FILES) - # existing_kwargs = obj_settings.SETTINGS_SOURCE_KWARGS - # existing_env_prefix = obj_settings.SETTINGS_SOURCE_ENV_PREFIX - - # new_config_files = SettingsUtils.format_config_file_list(config_files=config_files) - # new_kwargs = SettingsUtils.format_kwargs_dict(p_kwargs=kwargs) - - # if (config_files and new_config_files != existing_config_files) or (new_kwargs and new_kwargs != existing_kwargs) or (env_prefix and env_prefix != existing_env_prefix): - # config_file_message = f" Config files {new_config_files} were provided. Previously initialised with config files {existing_config_files}." - # kwargs_message = f" Kwargs {new_kwargs} were provided. Previously initialised with kwargs {existing_kwargs}." - # env_prefix_message = f" Env prefix {env_prefix} was provided. Previously initialised with env prefix {existing_env_prefix}." - # raise ValueError(f"Namespace '{settings_namespace}' is already initialised. {config_file_message} {kwargs_message} {env_prefix_message}") - - # print(f"Warning: Namespace '{settings_namespace}' is already initialised. The parameters have not changed.") - - - - - # # @classmethod - # def init_settings(self, - # settings_parameters: SettingsParameters) -> BaseSettings: - # # settings_namespace: str, - # # settings_class: Type[BaseSettings], - # # config_files: Optional[Union[UPath, str, List[UPath|str], Tuple[UPath|str]]] = None, - # # env_prefix: Optional[str] = None, - # # **kwargs) -> BaseSettings: - # """ - # Initializes the configuration for a given namespace. - - # Args: - # settings_namespace (str): The namespace for the configuration. - # settings_class (Type[BaseSettings]): The settings class to be used. - # config_files (Union[UPath, List[UPath]]): The configuration file or list of configuration files. - # kwargs (Dict[str, Any]): The keyword arguments to be combined. - # """ - - # # if not settings_namespace: - # # raise ValueError("settings_namespace cannot be empty.") - - # # if not settings_class: - # # raise ValueError("settings_class cannot be empty.") - - - # #Check if the namespace is already initialised - # if self.is_namespace_initialised(settings_parameters=settings_parameters): - - # #If it was already initialised, why are we trying to re-initialse it? Fail if parameters have changed. Pass if the same, but with a warning. - # # self.validate_init_existing_namespace(settings_namespace=settings_namespace, config_files=config_files, **kwargs) - - # #Get the existing settings object - # obj_settings: BaseSettings = self.get_config_object(settings_parameters=settings_parameters) - - # #Otherwise We have a new config to create - # else: - # ### HANDLE CONFIG FILES ### - # #Lets not do it this way! - - # # Process config files - # # config_files_sorted = SettingsFileHandler.separate_config_files(config_files) - - # # # Validate config files exist - # # SettingsFileHandler.validate_config_files_exist(config_files_sorted.env_files) - # # SettingsFileHandler.validate_config_files_exist(config_files_sorted.yaml_files) - # # SettingsFileHandler.validate_config_files_exist(config_files_sorted.toml_files) - - # # ### HANDLE KWARGS ### - # # self.validate_kwargs_keys(settings_class=settings_class, kwargs=kwargs) - - # # #Create the Settings object - # class_module = settings_parameters.settings_class.__module__ - # class_name = settings_parameters.settings_class.__name__ - # settings_class_ref: Type[BaseSettings] = getattr(import_module(name=class_module), class_name) - - # # #Create the parameters object - # # obj_settings_parameters = SettingsParameters.create( - # # namespace = settings_namespace, - # # config_files=config_files, - # # kwargs=kwargs, - # # settings_class=settings_class, - # # env_prefix=env_prefix - # # ) - - # #Create the settings object - # obj_settings = settings_class_ref( - # settings_parameters = settings_parameters - # ) - - # # obj_settings = settings_class_ref( - # # SETTINGS_SOURCE_ENV_FILES=config_files_sorted.env_files, - # # SETTINGS_SOURCE_YAML_FILES=config_files_sorted.yaml_files, - # # SETTINGS_SOURCE_TOML_FILES=config_files_sorted.toml_files, - # # SETTINGS_NAMESPACE=settings_namespace, - # # SETTINGS_CLASS = settings_class_ref, - # # SETTINGS_CLASS_NAME = settings_class.__name__, - # # **kwargs) - - # self.settings_object_cache[settings_parameters.__hash__()] = obj_settings - - # return obj_settings diff --git a/src/mountainash_settings/settings_parameters/__init__.py b/src/mountainash_settings/settings_parameters/__init__.py index 0e124c5..1e8dfa2 100644 --- a/src/mountainash_settings/settings_parameters/__init__.py +++ b/src/mountainash_settings/settings_parameters/__init__.py @@ -1,22 +1,12 @@ from .filehandler import SettingsFileHandler, SettingsFiles from .kwargshandler import SettingsKwargsHandler from .settings_parameters import SettingsParameters -from .utils import SettingsUtils -from .merge_framework import ( - GenericMerger, SettingsParameterMerger, FieldMergeUtils, - MergePriority, ValidationError, get_merger -) +from .merge_framework import ValidationError __all__ = [ - "SettingsParameters", - "SettingsUtils", + "SettingsParameters", "SettingsFileHandler", "SettingsKwargsHandler", "SettingsFiles", - "GenericMerger", - "SettingsParameterMerger", - "FieldMergeUtils", - "MergePriority", "ValidationError", - "get_merger" - ] +] diff --git a/src/mountainash_settings/settings_parameters/merge_framework.py b/src/mountainash_settings/settings_parameters/merge_framework.py index 54eb6de..3396671 100644 --- a/src/mountainash_settings/settings_parameters/merge_framework.py +++ b/src/mountainash_settings/settings_parameters/merge_framework.py @@ -1,231 +1,8 @@ """ -Simplified merge framework for eliminating duplicate merge patterns. - -Provides simple merge utilities that handle prioritization logic -while maintaining identical functionality to the original complex implementation. +Validation utilities for settings parameter operations. """ -from typing import Optional, Union, List, Any, Tuple, Dict -from upath import UPath -from .settings_parameters import SettingsParameters - class ValidationError(Exception): - """Exception for validation failures in merge operations.""" + """Exception for validation failures in settings parameter operations.""" pass - - -def _merge_simple(first: Any, second: Any, first_wins: bool = False) -> Any: - """Merge two simple values based on priority.""" - if first_wins: - return first or second - return second or first - - -def _merge_config_files(first: Optional[Tuple], second: Optional[Tuple], first_wins: bool = False) -> Optional[Tuple]: - """Merge configuration file tuples with deduplication.""" - if first is None and second is None: - return None - - if first_wins: - return first or second - - # Default behavior: combine and deduplicate - # Convert all paths to strings to handle mix of UPath and str types - merged = set(first or ()) | set(second or ()) - return tuple(sorted(str(p) for p in merged)) if merged else None - - -def _merge_kwargs(first: Optional[Dict], second: Optional[Dict], first_wins: bool = False) -> Optional[Dict]: - """Merge keyword argument dictionaries.""" - if first is None and second is None: - return None - - if first_wins: - return first or second - - # Default behavior: merge with second taking precedence - merged = dict(first or {}) | dict(second or {}) - # Handle special kwargs nesting - merged = merged.get("kwargs", merged) - return merged if merged else None - - -def _merge_settings_class(first: Optional[type], second: Optional[type], first_wins: bool = False) -> Optional[type]: - """Merge settings classes with compatibility validation.""" - if first is None and second is None: - return None - - # Validate compatibility if both are provided - if first is not None and second is not None and first != second: - raise ValidationError(f"Settings class must match for merging. first: {first} != second: {second}") - - if first_wins: - return first or second - return second or first - - -class SettingsParameterMerger: - """Simplified merger for SettingsParameters objects.""" - - def merge_with_object(self, - base: SettingsParameters, - other: SettingsParameters, - prioritise_base: bool = False) -> SettingsParameters: - """Merge two SettingsParameters objects.""" - if base is None: - raise ValidationError("Base SettingsParameters cannot be None") - - if other is None: - return base - - # Simple field-by-field merging - # For namespace, don't apply _init_namespace fallback until after merge - resolved_namespace = _merge_simple( - base.namespace, - other.namespace, - prioritise_base - ) - # Apply the DEFAULT fallback only if result is None - if resolved_namespace is None: - resolved_namespace = base._init_namespace(None) - - resolved_config_files = _merge_config_files( - base.config_files, other.config_files, prioritise_base - ) - - resolved_kwargs = _merge_kwargs( - base.kwargs, other.kwargs, prioritise_base - ) - - resolved_env_prefix = _merge_simple( - base.env_prefix, other.env_prefix, prioritise_base - ) - - resolved_settings_class = _merge_settings_class( - base.settings_class, other.settings_class, prioritise_base - ) - - resolved_secrets_dir = _merge_simple( - base.secrets_dir, other.secrets_dir, prioritise_base - ) - - return SettingsParameters.create( - settings_class=resolved_settings_class, - namespace=resolved_namespace, - config_files=resolved_config_files, - env_prefix=resolved_env_prefix, - secrets_dir=resolved_secrets_dir, - **(resolved_kwargs or {}) - ) - - def merge_with_params(self, - base: SettingsParameters, - namespace: Optional[str] = None, - config_files: Optional[Union[UPath, str, List[Union[UPath, str]]]] = None, - kwargs: Optional[Dict[str, Any]] = None, - env_prefix: Optional[str] = None, - secrets_dir: Optional[str] = None, - prioritise_base: bool = False) -> SettingsParameters: - """Merge SettingsParameters with individual parameters.""" - if base is None: - raise ValidationError("Base SettingsParameters cannot be None") - - # Convert config_files to proper format - from .filehandler import SettingsFileHandler - formatted_config_files = SettingsFileHandler.format_config_file_tuple(config_files) - - # Simple field-by-field merging - # For namespace, don't apply _init_namespace fallback until after merge - resolved_namespace = _merge_simple( - base.namespace, - namespace, - prioritise_base - ) - # Apply the DEFAULT fallback only if result is None - if resolved_namespace is None: - resolved_namespace = base._init_namespace(None) - - resolved_config_files = _merge_config_files( - base.config_files, formatted_config_files, prioritise_base - ) - - resolved_kwargs = _merge_kwargs( - base.kwargs, kwargs, prioritise_base - ) - - resolved_env_prefix = _merge_simple( - base.env_prefix, env_prefix, prioritise_base - ) - - resolved_secrets_dir = _merge_simple( - base.secrets_dir, secrets_dir, prioritise_base - ) - - return SettingsParameters.create( - settings_class=base.settings_class, - namespace=resolved_namespace, - config_files=resolved_config_files, - env_prefix=resolved_env_prefix, - secrets_dir=resolved_secrets_dir, - **(resolved_kwargs or {}) - ) - - -class FieldMergeUtils: - """Simple utility functions for merging specific field types.""" - - @staticmethod - def merge_namespaces(first: Optional[str] = None, second: Optional[str] = None) -> str: - """Merge namespace strings with default fallback.""" - return first or second or "DEFAULT" - - @staticmethod - def merge_env_prefixes(first: Optional[str] = None, second: Optional[str] = None) -> Optional[str]: - """Merge environment prefix strings.""" - return first or second - - @staticmethod - def merge_config_files_simple(first: Optional[Tuple] = None, second: Optional[Tuple] = None) -> Optional[Tuple]: - """Simple config file merge with deduplication.""" - return _merge_config_files(first, second, first_wins=False) - - @staticmethod - def merge_kwargs_simple(first: Optional[Dict] = None, second: Optional[Dict] = None) -> Optional[Dict]: - """Simple kwargs merge with second taking precedence.""" - return _merge_kwargs(first, second, first_wins=False) - - -# Global merger instance for easy access -_global_merger = SettingsParameterMerger() - - -def get_merger() -> SettingsParameterMerger: - """Get the global merger instance.""" - return _global_merger - - -# Legacy compatibility exports (unused but maintain API) -class MergePriority: - """Legacy enum compatibility.""" - FIRST_WINS = "first_wins" - SECOND_WINS = "second_wins" - COMBINE = "combine" - - -class GenericMerger: - """Legacy compatibility class.""" - def __init__(self): - self._merger = _global_merger - - def merge_field(self, field_name: str, first_value: Any, second_value: Any, - strategy_name: str = 'simple', prioritise_first: bool = False) -> Any: - """Legacy compatibility method.""" - return _merge_simple(first_value, second_value, prioritise_first) - - def merge_fields(self, field_specs: Dict, prioritise_first: bool = False) -> Dict: - """Legacy compatibility method.""" - results = {} - for field_name, spec in field_specs.items(): - results[field_name] = _merge_simple(spec['first'], spec['second'], prioritise_first) - return results \ No newline at end of file diff --git a/src/mountainash_settings/settings_parameters/settings_parameters.py b/src/mountainash_settings/settings_parameters/settings_parameters.py index 04275a5..c1c9707 100644 --- a/src/mountainash_settings/settings_parameters/settings_parameters.py +++ b/src/mountainash_settings/settings_parameters/settings_parameters.py @@ -24,14 +24,13 @@ class SettingsParameters(): parameters, enabling cache reuse when only runtime parameters differ. Structural Parameters (affect cache identity): - namespace: The namespace of the settings object. Used to group settings together. config_files: The configuration files that the settings object will use to load settings. settings_class: The class/type that will be used to create the settings object. env_prefix: Environment variable prefix for this settings instance. + secrets_dir: Directory for secrets storage (pydantic-settings reads from it). Runtime Parameters (don't affect cache identity): kwargs: Additional keyword arguments for runtime overrides. - secrets_dir: Directory for secrets storage (runtime configuration). Caching Strategy: Two SettingsParameters with identical structural parameters but different @@ -40,12 +39,11 @@ class SettingsParameters(): Example: # These will use the same cached settings object: - params1 = SettingsParameters(namespace="app", config_files=["config.yaml"], + params1 = SettingsParameters(config_files=["config.yaml"], kwargs={"debug": True}) - params2 = SettingsParameters(namespace="app", config_files=["config.yaml"], + params2 = SettingsParameters(config_files=["config.yaml"], kwargs={"log_level": "INFO"}) """ - namespace: Optional[str] = None config_files: Optional[List[str|UPath]|Tuple[str|UPath]] = None settings_class: Optional[Type[BaseSettings]] = None env_prefix: Optional[str] = None @@ -94,12 +92,12 @@ def __hash__(self): Custom hash implementation for efficient settings caching strategy. Only includes 'structural' parameters that define the core configuration identity: - - namespace: Settings grouping identifier - config_files: Source configuration files - settings_class: Type of settings object - env_prefix: Environment variable prefix + - secrets_dir: Directory for pydantic-settings secrets files - Deliberately EXCLUDES runtime parameters (kwargs, secrets_dir) to enable + Deliberately EXCLUDES runtime parameters (kwargs) to enable cache reuse when only dynamic overrides differ. This allows efficient retrieval of cached settings objects when the core @@ -108,10 +106,10 @@ def __hash__(self): Example: These two parameter sets will have the same hash (same cached object): - params1 = SettingsParameters(namespace="app", config_files=["config.yaml"], + params1 = SettingsParameters(config_files=["config.yaml"], settings_class=AppSettings, kwargs={"debug": True}) - params2 = SettingsParameters(namespace="app", config_files=["config.yaml"], + params2 = SettingsParameters(config_files=["config.yaml"], settings_class=AppSettings, kwargs={"log_level": "INFO"}) Returns: @@ -120,11 +118,11 @@ def __hash__(self): hashable_config_files = SettingsFileHandler.format_config_file_tuple(self.config_files) hashable_attrs = tuple([ - self.namespace, hashable_config_files, self.settings_class, self.env_prefix, - # Deliberately exclude: self.kwargs, self.secrets_dir + self.secrets_dir, + # Deliberately exclude: self.kwargs ]) return hash(hashable_attrs) @@ -134,7 +132,7 @@ def __eq__(self, other): Equality based on the same structural parameters used in __hash__. Two SettingsParameters are equal if their core configuration identity - matches, regardless of runtime parameter differences. + matches, regardless of runtime parameter differences (kwargs). This supports the caching strategy where settings objects with the same structural configuration can be reused even when runtime overrides differ. @@ -152,11 +150,11 @@ def __eq__(self, other): other_hashable_config_files = SettingsFileHandler.format_config_file_tuple(other.config_files) return ( - self.namespace == other.namespace and self_hashable_config_files == other_hashable_config_files and self.settings_class == other.settings_class and - self.env_prefix == other.env_prefix - # Deliberately exclude: kwargs, secrets_dir comparison + self.env_prefix == other.env_prefix and + self.secrets_dir == other.secrets_dir + # Deliberately exclude: kwargs comparison ) @@ -173,23 +171,19 @@ def get_settings(self, **kwargs) -> MountainAshBaseSettings: # Creation methods @classmethod def create(cls, - namespace: Optional[str] = None, config_files: Optional[str|UPath|List[str|UPath]|Tuple[str|UPath]] = None, settings_class: Optional[Type[BaseSettings]] = None, env_prefix: Optional[str] = None, secrets_dir: Optional[str] = None, - **kwargs: Optional[Dict[str, Any]] + **kwargs: Any ) -> 'SettingsParameters': #Combine the parameters into a single object - # resolved_namespace = cls._init_namespace(namespace) resolved_config_files = SettingsFileHandler.format_config_file_tuple(config_files) - # merged_kwargs = SettingsKwargsHandler.merge_kwargs(kw_params, kwargs) if kwargs else kw_params resolved_kwargs = SettingsKwargsHandler.format_kwargs_dict(kwargs) if kwargs else None return cls( - namespace=namespace, config_files=resolved_config_files, settings_class=settings_class, env_prefix=env_prefix, @@ -199,15 +193,87 @@ def create(cls, - @staticmethod - def _init_namespace(namespace: Optional[str]) -> str: - return namespace or "DEFAULT" + @classmethod + def merge(cls, + base: 'SettingsParameters', + other: Optional['SettingsParameters'] = None, + prioritise_base: bool = False + ) -> 'SettingsParameters': + """ + Merge two SettingsParameters objects. + + Per-field strategies: + - config_files: combined and deduplicated + - settings_class: must match if both provided (raises ValueError) + - scalars (env_prefix, secrets_dir): last wins (or first if prioritise_base) + - kwargs: merged dict, second takes precedence (or first if prioritise_base) + + Args: + base: The base parameters. + other: Parameters to merge in. If None, returns base. + prioritise_base: If True, base values win over other values. + + Returns: + A new SettingsParameters with merged values. + + Raises: + ValueError: If base is None or settings_class values conflict. + """ + if base is None: + raise ValueError("Base SettingsParameters cannot be None") + if other is None: + return base + + # Config files: combine and deduplicate + if base.config_files is None and other.config_files is None: + merged_config_files = None + elif prioritise_base: + merged_config_files = base.config_files or other.config_files + else: + merged = set(base.config_files or ()) | set(other.config_files or ()) + merged_config_files = tuple(sorted(str(p) for p in merged)) if merged else None + + # Settings class: validate compatibility + if base.settings_class is not None and other.settings_class is not None: + if base.settings_class != other.settings_class: + raise ValueError( + f"Settings class must match for merging. " + f"base: {base.settings_class} != other: {other.settings_class}" + ) + if prioritise_base: + merged_class = base.settings_class or other.settings_class + else: + merged_class = other.settings_class or base.settings_class + + # Scalars: simple priority + if prioritise_base: + merged_env_prefix = base.env_prefix or other.env_prefix + merged_secrets_dir = base.secrets_dir or other.secrets_dir + else: + merged_env_prefix = other.env_prefix or base.env_prefix + merged_secrets_dir = other.secrets_dir or base.secrets_dir + + # Kwargs: merge dicts + if base.kwargs is None and other.kwargs is None: + merged_kwargs = None + elif prioritise_base: + merged_kwargs = base.kwargs or other.kwargs + else: + merged_kwargs = dict(base.kwargs or {}) | dict(other.kwargs or {}) + merged_kwargs = merged_kwargs if merged_kwargs else None + + return cls.create( + settings_class=merged_class, + config_files=merged_config_files, + env_prefix=merged_env_prefix, + secrets_dir=merged_secrets_dir, + **(merged_kwargs or {}) + ) #Export / retrieve values def to_dict(self) -> Dict[str, Any]: return { - 'namespace': self.namespace, 'config_files': list(self.config_files) if self.config_files else None, 'kwargs': self.get_all_kwargs() if self.kwargs else None, 'settings_class': self.settings_class, diff --git a/src/mountainash_settings/settings_parameters/utils.py b/src/mountainash_settings/settings_parameters/utils.py deleted file mode 100644 index 9e1530a..0000000 --- a/src/mountainash_settings/settings_parameters/utils.py +++ /dev/null @@ -1,143 +0,0 @@ - -from typing import Optional, Union, List, Any, Tuple, Dict - -from upath import UPath - -from .settings_parameters import SettingsParameters -from .filehandler import SettingsFileHandler -from .kwargshandler import SettingsKwargsHandler -from .merge_framework import get_merger, FieldMergeUtils - -class SettingsUtils: - - """ - Utility class for handling settings parameters. - """ - - #Hashable format for settings parameters - default_namespace: str = "DEFAULT" - - - @classmethod - def merge_settings_parameter_objects(cls, - base: SettingsParameters, - other: SettingsParameters, - prioritise_self: bool = False - ) -> SettingsParameters: - """ - Merge two SettingsParameters objects using the generic merge framework. - - Eliminates ~45 lines of duplicate prioritization logic by delegating - to the generic merger with proper validation and field-specific strategies. - """ - merger = get_merger() - return merger.merge_with_object( - base=base, - other=other, - prioritise_base=prioritise_self - ) - - @classmethod - def merge_settings_parameters(cls, - base: SettingsParameters, - namespace: Optional[str] = None, - config_files: Optional[Union[UPath, str, List[Union[UPath, str]]]] = None, - kwargs: Optional[Dict[str, Any]] = None, - env_prefix: Optional[str] = None, - secrets_dir: Optional[str] = None, - prioritise_self: Optional[bool] = False - ) -> 'SettingsParameters': - """ - Merge SettingsParameters with individual parameters using the generic merge framework. - - Eliminates ~30 lines of duplicate prioritization logic by delegating - to the generic merger with parameter-specific handling. - """ - merger = get_merger() - return merger.merge_with_params( - base=base, - namespace=namespace, - config_files=config_files, - kwargs=kwargs, - env_prefix=env_prefix, - secrets_dir=secrets_dir, - prioritise_base=prioritise_self - ) - - - - #Translation functions between mutable and immutable - - ############################################################################################################ - # Parameter formatting - - @staticmethod - def format_kwargs_dict( - p_kwargs: None | Dict[str,Any] | Tuple[Any,Any] = None - ) -> Optional[Dict[str,Any]]: - - return SettingsKwargsHandler.format_kwargs_dict(p_kwargs=p_kwargs) - - - @staticmethod - def format_kwargs_tuple( - p_kwargs: None | Dict[str,Any] | Tuple[Any,Any] = None - ) -> Optional[Tuple[Any,Any]]: - - return SettingsKwargsHandler.format_kwargs_tuple(p_kwargs=p_kwargs) - - - - @staticmethod - def format_config_file_list( - config_files: Optional[Union[UPath, str, List[UPath|str], Tuple[UPath|str]]] = None - ) -> Optional[List[UPath|str]]: - - return SettingsFileHandler.format_config_file_list(config_files=config_files) - - - @staticmethod - def format_config_file_tuple( - config_files: Optional[Union[UPath, str, List[UPath|str], Tuple[UPath|str]]] = None - ) -> Optional[Tuple[UPath|str]]: - - return SettingsFileHandler.format_config_file_tuple(config_files=config_files) - - - # Resolve / Merge values - simplified using FieldMergeUtils - @staticmethod - def merge_namespaces(namespace1: Optional[str] = None, - namespace2: Optional[str] = None) -> str: - """Merge namespace strings using the generic merge framework.""" - return FieldMergeUtils.merge_namespaces(namespace1, namespace2) - - @staticmethod - def merge_env_prefix(env_prefix1: Optional[str] = None, - env_prefix2: Optional[str] = None) -> Optional[str]: - """Merge environment prefix strings using the generic merge framework.""" - return FieldMergeUtils.merge_env_prefixes(env_prefix1, env_prefix2) - - @staticmethod - def merge_config_files(config_files1: Optional[Tuple[Union[UPath, str], ...]] = None, - config_files2: Optional[Tuple[Union[UPath, str], ...]] = None) -> Optional[Tuple[Union[UPath, str], ...]]: - """Merge config files using the generic merge framework with proper deduplication.""" - return FieldMergeUtils.merge_config_files_simple(config_files1, config_files2) - - @staticmethod - def merge_kwargs(kwargs1: Optional[Tuple[Tuple[str, Any], ...]] = None, - kwargs2: Optional[Tuple[Tuple[str, Any], ...]] = None) -> Optional[Tuple[Tuple[str, Any], ...]]: - """ - Merge kwargs using the generic merge framework. - - Note: Converts tuple format to dict for processing, then back to maintain compatibility. - """ - # Convert tuple format to dict format for processing - dict1 = dict(kwargs1) if kwargs1 else None - dict2 = dict(kwargs2) if kwargs2 else None - - merged_dict = FieldMergeUtils.merge_kwargs_simple(dict1, dict2) - - # Convert back to tuple format for compatibility - if merged_dict: - return tuple(merged_dict.items()) - return None diff --git a/tests/conftest.py b/tests/conftest.py index 160b2d9..206bdfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -60,7 +60,7 @@ def isolated_cache(): Provides an isolated cache environment for tests. Note: This doesn't fully clear the global LRU cache, but uses - unique namespaces to ensure test isolation. + unique parameter combinations to ensure test isolation. """ from mountainash_settings import SettingsManager diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index 8f6e30b..694a1b7 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -48,7 +48,7 @@ # # Parameters Fixtures (from parameters.py) # "basic_settings_parameters", - # "settings_parameters_with_namespace", + # (removed: settings_parameters_with_namespace) # "settings_parameters_with_prefix", # "settings_parameters_with_config_file", # "settings_parameters_with_multiple_files", @@ -59,7 +59,7 @@ # "sample_kwargs", # "create_settings_parameters", # "parametrized_settings_class", - # "parametrized_namespace", + # (removed: parametrized_namespace) # "parametrized_env_prefix", # "parametrized_kwargs", diff --git a/tests/fixtures/instances.py b/tests/fixtures/instances.py index 51ebd48..60ee4b7 100644 --- a/tests/fixtures/instances.py +++ b/tests/fixtures/instances.py @@ -175,7 +175,7 @@ def isolated_settings_manager(): Note: This doesn't fully isolate the global cache, but provides a fresh manager instance. For true isolation, tests should use - unique namespaces. + unique parameter combinations (e.g. different env_prefix values). """ return SettingsManager() @@ -188,7 +188,6 @@ def settings_with_runtime_override(basic_settings_parameters): This tests the runtime override functionality. """ params_with_override = basic_settings_parameters.__class__.create( - namespace=basic_settings_parameters.namespace, settings_class=basic_settings_parameters.settings_class, TEST_VAL_1="runtime_override_value" ) diff --git a/tests/fixtures/parameters.py b/tests/fixtures/parameters.py index 6840d9d..2839194 100644 --- a/tests/fixtures/parameters.py +++ b/tests/fixtures/parameters.py @@ -23,16 +23,6 @@ def basic_settings_parameters(): """Provides basic SettingsParameters for simple testing.""" return SettingsParameters.create( - namespace="test", - settings_class=TestSettings - ) - - -@pytest.fixture -def settings_parameters_with_namespace(): - """Provides SettingsParameters with a specific namespace.""" - return SettingsParameters.create( - namespace="custom_namespace", settings_class=TestSettings ) @@ -41,7 +31,6 @@ def settings_parameters_with_namespace(): def settings_parameters_with_prefix(): """Provides SettingsParameters with environment prefix.""" return SettingsParameters.create( - namespace="test", settings_class=TestSettings, env_prefix="TEST_" ) @@ -51,7 +40,6 @@ def settings_parameters_with_prefix(): def settings_parameters_with_config_file(temp_yaml_file): """Provides SettingsParameters with a config file.""" return SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=temp_yaml_file ) @@ -61,7 +49,6 @@ def settings_parameters_with_config_file(temp_yaml_file): def settings_parameters_with_multiple_files(temp_multiple_yaml_files): """Provides SettingsParameters with multiple config files.""" return SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=temp_multiple_yaml_files ) @@ -71,7 +58,6 @@ def settings_parameters_with_multiple_files(temp_multiple_yaml_files): def settings_parameters_with_kwargs(): """Provides SettingsParameters with kwargs.""" return SettingsParameters.create( - namespace="test", settings_class=TestSettings, TEST_VAL_1="kwarg_value_1", TEST_VAL_2="kwarg_value_2" @@ -84,7 +70,6 @@ def settings_parameters_with_secrets_dir(temp_dir): secrets_dir = temp_dir / "secrets" secrets_dir.mkdir() return SettingsParameters.create( - namespace="test", settings_class=TestSettings, secrets_dir=str(secrets_dir) ) @@ -97,7 +82,6 @@ def settings_parameters_full_config(temp_yaml_file, temp_dir): secrets_dir.mkdir() return SettingsParameters.create( - namespace="full_test", config_files=temp_yaml_file, settings_class=TestSettings, env_prefix="FULL_", @@ -116,7 +100,6 @@ def sample_settings_parameters(): This is an alias for backwards compatibility with existing tests. """ return SettingsParameters.create( - namespace="test", config_files="test_config.yaml", env_prefix="TEST_" ) @@ -140,13 +123,11 @@ def create_settings_parameters(): Usage: params = create_settings_parameters( - namespace="my_test", settings_class=TestSettings, custom_key="custom_value" ) """ def _create( - namespace: str = None, config_files: Any = None, settings_class: type = TestSettings, env_prefix: str = None, @@ -157,7 +138,6 @@ def _create( Create a SettingsParameters object with custom configuration. Args: - namespace: Namespace for settings config_files: Configuration files to use settings_class: Settings class to use env_prefix: Environment variable prefix @@ -168,7 +148,6 @@ def _create( Configured SettingsParameters object """ return SettingsParameters.create( - namespace=namespace, config_files=config_files, settings_class=settings_class, env_prefix=env_prefix, @@ -191,17 +170,6 @@ def parametrized_settings_class(request): return request.param -@pytest.fixture(params=[ - None, - "test_namespace", - "production", - "development" -]) -def parametrized_namespace(request): - """Provides different namespaces for parametrized testing.""" - return request.param - - @pytest.fixture(params=[ None, "TEST_", diff --git a/tests/test_app_settings.py b/tests/test_app_settings.py index b6106ec..aeafec8 100644 --- a/tests/test_app_settings.py +++ b/tests/test_app_settings.py @@ -25,7 +25,7 @@ def test_initialization_with_defaults_succeeds(self): settings = AppSettings() assert settings.DEBUG is False assert settings.LOCALE_TIMEZONE == "UTC" - assert settings.PLATFORM_SLASH is not None + # assert settings.PLATFORM_SLASH is not None def test_pandera_framework_field_exists(self, app_settings_instance): """Test that the Pandas framework field exists and has correct default.""" @@ -41,7 +41,7 @@ def test_initialization_with_config_files_accepts_list(self, temp_config_files): assert settings is not None def test_initialization_with_settings_parameters_succeeds(self): - params = SettingsParameters.create(namespace="test") + params = SettingsParameters.create() settings = AppSettings(settings_parameters=params) assert settings is not None diff --git a/tests/test_base_settings.py b/tests/test_base_settings.py index 0c04cfc..b1b02f1 100644 --- a/tests/test_base_settings.py +++ b/tests/test_base_settings.py @@ -42,7 +42,6 @@ def __init__( def get_test_settings(settings_parameters: SettingsParameters, settings_class: Optional[Type[TestSettings]] = TestSettings, - settings_namespace: Optional[str] = None, config_files: Optional[Union[UPath, str, List[UPath|str]]] = None, **kwargs ) -> TestSettings: @@ -50,7 +49,6 @@ def get_test_settings(settings_parameters: SettingsParameters, test_settings: TestSettings = TestSettings.get_settings(settings_parameters=settings_parameters, settings_class=settings_class, - settings_namespace=settings_namespace, config_files=config_files, **kwargs) if isinstance(test_settings, TestSettings): @@ -62,13 +60,6 @@ def get_test_settings(settings_parameters: SettingsParameters, ################ # TESTS # -def test_init_sets_namespace(): - namespace = "test" - sp = SettingsParameters.create(settings_class=TestSettings, namespace = namespace) - - settings = TestSettings(settings_parameters=sp) - assert settings.SETTINGS_NAMESPACE == namespace - def test_init_sets_kwargs(): kwargs: dict[str, Any] = {"TEST_VAL_1": "value1", "TEST_VAL_2": "value2"} @@ -96,27 +87,15 @@ def test_init_sets_env_prefix(): assert settings.SETTINGS_SOURCE_ENV_PREFIX == prefix -# def test_init_removes_special_kwargs(): -# kwargs: dict[str, Any] = {"SETTINGS_NAMESPACE": "test", "TEST_VAL_1": "value1"} -# settings = TestSettings(**kwargs) - -# if settings.SETTINGS_SOURCE_KWARGS: -# assert "SETTINGS_NAMESPACE" not in settings.SETTINGS_SOURCE_KWARGS - - - ## ============================================================ ## Test using variables with a prefix in the test config files, and in kwargs! - - def test_init_no_file(settings_manager: SettingsManager): - namespace = "test_init_no_file" config_files: List[Any] = []#"./tests/config_testing1.env"] kwargs = {} - settings_parameters = SettingsParameters.create( settings_class=TestSettings,namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create( settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) @@ -125,11 +104,10 @@ def test_init_no_file(settings_manager: SettingsManager): assert app_settings.TEST_VAL_2 is None def test_init_no_file_kwarg(settings_manager: SettingsManager): - namespace = "test_init_no_file_kwarg" config_files: List[Any] = []#"./tests/config_testing1.env"] kwargs = {"TEST_VAL_1": "ABC", "TEST_VAL_2": "XYZ"} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) @@ -140,11 +118,10 @@ def test_init_no_file_kwarg(settings_manager: SettingsManager): def test_init_file(settings_manager: SettingsManager): - namespace = "test_init_file" config_files: List[Any] = ["./tests/config_testing1.env"] kwargs = {} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) @@ -154,11 +131,10 @@ def test_init_file(settings_manager: SettingsManager): def test_init_file_and_kwarg(settings_manager: SettingsManager): - namespace = "test_init_file_and_kwarg" config_files: List[Any] = ["./tests/config_testing1.env"] kwargs = {"TEST_VAL_1": "ABC"} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) @@ -168,11 +144,10 @@ def test_init_file_and_kwarg(settings_manager: SettingsManager): assert app_settings.TEST_VAL_2 == "TEST_VAL_2_File_1" def test_init_file_and_kwarg2(settings_manager: SettingsManager): - namespace = "test_init_file_and_kwarg2" config_files: List[Any] = ["./tests/config_testing1.env"] kwargs = {"TEST_VAL_2": "XYZ"} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) @@ -184,12 +159,10 @@ def test_init_file_and_kwarg2(settings_manager: SettingsManager): def test_init_file_prefix1(settings_manager: SettingsManager): - namespace = "test_init_file_prefix1" config_files: List[Any] = ["./tests/config_testing1.env"] kwargs = {} settings_parameters = SettingsParameters.create(settings_class=TestSettings, - namespace=namespace, config_files=config_files, env_prefix="PREFIX_", kwargs=kwargs) @@ -201,13 +174,10 @@ def test_init_file_prefix1(settings_manager: SettingsManager): assert app_settings.TEST_VAL_2 == "TEST_VAL_2_File_1" def test_init_file_prefix2(settings_manager: SettingsManager): - - namespace = "test_init_file_prefix2" config_files: List[Any] = ["./tests/config_testing_prefix1.env"] kwargs = {} settings_parameters = SettingsParameters.create(settings_class=TestSettings, - namespace=namespace, config_files=config_files, env_prefix="PREFIX_", kwargs=kwargs) @@ -219,11 +189,10 @@ def test_init_file_prefix2(settings_manager: SettingsManager): assert app_settings.TEST_VAL_2 == "TEST_VAL_2_File_Prefix1" def test_init_file_prefix3(settings_manager: SettingsManager): - namespace = "test_init_file_prefix3r" config_files: List[Any] = ["./tests/config_testing_prefix1.env"] kwargs = {} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) @@ -233,96 +202,12 @@ def test_init_file_prefix3(settings_manager: SettingsManager): -# One File - No prefix -# def test_init_config_valid_init_file(settings_manager: SettingsManager): -# namespace = "test_init_config_valid_init_file" -# config_files: List[Any] = ["./tests/config_testing1.env"] -# kwargs = {} - -# settings_parameters = SettingsParameters.create(namespace=namespace, settings_class=TestSettings, config_files=config_files, kwargs=kwargs) - -# app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) - -# with check: -# assert app_settings.TEST_VAL_1 == "ABC" -# assert app_settings.TEST_VAL_2 == "000001" - -# def test_init_config_valid_init_file_2(settings_manager: SettingsManager): -# namespace = "test_init_config_valid_init_file_2" -# config_files: List[Any] = ["./tests/config_testing2.env"] -# kwargs = {} - -# settings_parameters = SettingsParameters.create(namespace=namespace, settings_class=TestSettings, config_files=config_files, kwargs=kwargs) - -# app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) - -# with check: -# assert app_settings.TEST_VAL_1 == "ABC-2" -# assert app_settings.TEST_VAL_2 == "000001-2" - -# # One File with prefix -# def test_init_config_valid_init_file_prefix(settings_manager: SettingsManager): -# namespace = "test_init_config_valid_init_file_prefix" -# config_files: List[str] = ["./tests/config_testing1.env"] -# kwargs = {"_env_prefix": "TESTING_PREFIX_"} - -# settings_parameters = SettingsParameters.create(namespace=namespace, settings_class=TestSettings, config_files=config_files, kwargs=kwargs) - -# app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) - -# with check: -# assert app_settings.TEST_VAL_1 == "JKL" -# assert app_settings.TEST_VAL_2 == "000002" - -# def test_init_config_valid_init_file_2_prefix(settings_manager: SettingsManager): -# namespace = "test_init_config_valid_init_file_2_prefix" -# config_files: List[Any] = ["./tests/config_testing2.env"] -# kwargs = {"_env_prefix": "TESTING_PREFIX_"} - -# settings_parameters = SettingsParameters.create(namespace=namespace, settings_class=TestSettings, config_files=config_files, kwargs=kwargs) - -# app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) - -# with check: -# assert app_settings.TEST_VAL_1 == "JKL-2" -# assert app_settings.TEST_VAL_2 == "000002-2" - -# # Two files - -# def test_init_config_valid_init_two_files_prefix(settings_manager: SettingsManager): -# # Arrange -# namespace = "test_init_config_valid_init_two_files_prefix" -# config_files: List[Any] = ["./tests/config_testing1.env", "./tests/config_testing2.env"] -# kwargs = {"_env_prefix": "TESTING_PREFIX_"} - -# settings_parameters = SettingsParameters.create(namespace=namespace, settings_class=TestSettings, config_files=config_files, kwargs=kwargs) - -# app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) - -# with check: -# assert app_settings.TEST_VAL_1 == "JKL-2" -# assert app_settings.TEST_VAL_2 == "000002-2" -# def test_init_config_valid_init_two_files_reverse_prefix(settings_manager: SettingsManager): -# namespace = "test_init_config_valid_init_two_files_reverse_prefix" -# config_files: List[Any] = ["./tests/config_testing2.env", "./tests/config_testing1.env"] -# kwargs = {"_env_prefix": "TESTING_PREFIX_"} - -# settings_parameters = SettingsParameters.create(namespace=namespace, settings_class=TestSettings, config_files=config_files, kwargs=kwargs) - -# app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) - -# with check: -# assert app_settings.TEST_VAL_1 == "JKL" -# assert app_settings.TEST_VAL_2 == "000002" - - def test_init_config_valid_init_two_files_noprefix(settings_manager: SettingsManager): # Arrange - namespace = "test_init_config_valid_init_two_files_noprefix" config_files: List[Any] = [ "./tests/config_testing1.env", "./tests/config_testing2.env"] kwargs = {} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) #TEST_VAL_2 was 000002 in the file, but over-ridden by the kwarg @@ -331,11 +216,10 @@ def test_init_config_valid_init_two_files_noprefix(settings_manager: SettingsMan assert app_settings.TEST_VAL_2 == "TEST_VAL_2_File_2" def test_init_config_valid_init_files_reverse_noprefix(settings_manager: SettingsManager): - namespace = "test_init_config_valid_init_files_reverse_noprefix" config_files: List[Any] = ["./tests/config_testing2.env", "./tests/config_testing1.env"] kwargs = {} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) #TEST_VAL_2 was 000002 in the file, but over-ridden by the kwarg @@ -348,11 +232,10 @@ def test_init_config_valid_init_files_reverse_noprefix(settings_manager: Setting def test_init_config_valid_init_files_override_and_kwargs_noprefix(settings_manager: SettingsManager): # Arrange - namespace = "test_init_config_valid_init_files_override_and_kwargs_noprefix" config_files: List[Any] = ["./tests/config_testing1.env"] kwargs = {"TEST_VAL_2": "000003"} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) with check: @@ -362,11 +245,10 @@ def test_init_config_valid_init_files_override_and_kwargs_noprefix(settings_mana def test_init_config_valid_init_files_override_and_kwargs_noprefix2(settings_manager: SettingsManager): # Arrange - namespace = "test_init_config_valid_init_files_override_and_kwargs_noprefix2" config_files: List[Any] = [ "./tests/config_testing2.env"] kwargs = {"TEST_VAL_1": "ABC"} - settings_parameters = SettingsParameters.create(settings_class=TestSettings, namespace=namespace, config_files=config_files, kwargs=kwargs) + settings_parameters = SettingsParameters.create(settings_class=TestSettings, config_files=config_files, kwargs=kwargs) app_settings: TestSettings = get_test_settings(settings_parameters=settings_parameters) #TEST_VAL_2 was 000002 in the file, but over-ridden by the kwarg diff --git a/tests/test_base_settings_coverage.py b/tests/test_base_settings_coverage.py index 9dbf6c7..73dff32 100644 --- a/tests/test_base_settings_coverage.py +++ b/tests/test_base_settings_coverage.py @@ -51,7 +51,6 @@ def test_get_settings_infers_class_from_caller(self, isolated_settings_manager): """Test that get_settings infers class when settings_class=None.""" # Call get_settings from TestSettings class without specifying settings_class settings = TestSettings.get_settings( - settings_namespace="test_infer_class", settings_class=None ) @@ -63,7 +62,6 @@ def test_get_settings_infers_class_from_caller(self, isolated_settings_manager): def test_get_settings_with_explicit_class(self, isolated_settings_manager): """Test that get_settings works with explicit class.""" settings = TestSettings.get_settings( - settings_namespace="test_explicit_class", settings_class=TestSettings ) @@ -74,7 +72,6 @@ def test_get_settings_with_explicit_class(self, isolated_settings_manager): def test_get_settings_type_validation_passes(self, isolated_settings_manager): """Test that get_settings validates instance type correctly.""" settings = TestSettings.get_settings( - settings_namespace="test_type_valid", settings_class=TestSettings ) @@ -85,7 +82,6 @@ def test_get_settings_type_validation_passes(self, isolated_settings_manager): def test_get_settings_with_parameters_object(self, isolated_settings_manager): """Test get_settings with SettingsParameters object.""" params = SettingsParameters.create( - namespace="test_params_obj", settings_class=TestSettings, TEST_VAL_1="param_value" ) @@ -104,51 +100,29 @@ def test_hash_with_basic_settings(self): """Test hash of basic settings object.""" settings1 = TestSettings( settings_parameters=SettingsParameters.create( - namespace="test_hash", settings_class=TestSettings ) ) settings2 = TestSettings( settings_parameters=SettingsParameters.create( - namespace="test_hash", settings_class=TestSettings ) ) - # Same namespace and class should produce same hash + # Same class should produce same hash assert hash(settings1) == hash(settings2) - @pytest.mark.unit - def test_hash_different_namespaces(self): - """Test that different namespaces produce different hashes.""" - settings1 = TestSettings( - settings_parameters=SettingsParameters.create( - namespace="namespace1", - settings_class=TestSettings - ) - ) - settings2 = TestSettings( - settings_parameters=SettingsParameters.create( - namespace="namespace2", - settings_class=TestSettings - ) - ) - - assert hash(settings1) != hash(settings2) - @pytest.mark.unit def test_hash_with_config_files(self, temp_yaml_file, temp_toml_file): """Test hash includes config files.""" settings1 = TestSettings( settings_parameters=SettingsParameters.create( - namespace="test_hash", settings_class=TestSettings, config_files=[temp_yaml_file] ) ) settings2 = TestSettings( settings_parameters=SettingsParameters.create( - namespace="test_hash", settings_class=TestSettings, config_files=[temp_toml_file] ) @@ -162,14 +136,12 @@ def test_hash_with_env_prefix(self): """Test hash includes env_prefix.""" settings1 = TestSettings( settings_parameters=SettingsParameters.create( - namespace="test_hash", settings_class=TestSettings, env_prefix="PREFIX1_" ) ) settings2 = TestSettings( settings_parameters=SettingsParameters.create( - namespace="test_hash", settings_class=TestSettings, env_prefix="PREFIX2_" ) @@ -183,7 +155,6 @@ def test_hash_with_none_values(self): """Test hash handles None values correctly.""" settings = TestSettings( settings_parameters=SettingsParameters.create( - namespace="test_hash_none", settings_class=TestSettings ) ) @@ -406,7 +377,6 @@ class TestExtractSettingsParameters: def test_extract_basic_parameters(self): """Test extracting basic parameters.""" original_params = SettingsParameters.create( - namespace="test_extract", settings_class=TestSettings, TEST_VAL_1="value1" ) @@ -414,7 +384,6 @@ def test_extract_basic_parameters(self): extracted = settings.extract_settings_parameters() - assert extracted.namespace == "test_extract" assert extracted.settings_class is TestSettings assert extracted.kwargs["TEST_VAL_1"] == "value1" @@ -422,7 +391,6 @@ def test_extract_basic_parameters(self): def test_extract_with_config_files(self, temp_yaml_file, temp_toml_file): """Test extracting parameters with config files.""" original_params = SettingsParameters.create( - namespace="test_extract", settings_class=TestSettings, config_files=[temp_yaml_file, temp_toml_file] ) @@ -430,7 +398,6 @@ def test_extract_with_config_files(self, temp_yaml_file, temp_toml_file): extracted = settings.extract_settings_parameters() - assert extracted.namespace == "test_extract" assert extracted.config_files is not None # Config files should be separated and included config_files_str = [str(f) for f in extracted.config_files] @@ -441,7 +408,6 @@ def test_extract_with_config_files(self, temp_yaml_file, temp_toml_file): def test_extract_with_env_prefix(self): """Test extracting parameters with env_prefix.""" original_params = SettingsParameters.create( - namespace="test_extract", settings_class=TestSettings, env_prefix="TEST_" ) @@ -455,7 +421,6 @@ def test_extract_with_env_prefix(self): def test_extract_with_all_file_types(self, temp_env_file, temp_yaml_file, temp_toml_file, temp_json_file): """Test extracting parameters with multiple file types.""" original_params = SettingsParameters.create( - namespace="test_extract_all", settings_class=TestSettings, config_files=[temp_env_file, temp_yaml_file, temp_toml_file, temp_json_file] ) @@ -471,14 +436,12 @@ def test_extract_with_all_file_types(self, temp_env_file, temp_yaml_file, temp_t def test_extract_with_none_values(self): """Test extracting parameters with None values.""" original_params = SettingsParameters.create( - namespace="test_extract_none", settings_class=TestSettings ) settings = TestSettings(settings_parameters=original_params) extracted = settings.extract_settings_parameters() - assert extracted.namespace == "test_extract_none" assert extracted.settings_class is TestSettings # None values should be handled gracefully @@ -486,7 +449,6 @@ def test_extract_with_none_values(self): def test_extract_preserves_kwargs(self): """Test that extract preserves kwargs.""" original_params = SettingsParameters.create( - namespace="test_extract_kwargs", settings_class=TestSettings, TEST_VAL_1="value1", TEST_VAL_2="value2" @@ -510,9 +472,6 @@ def test_post_init_default_does_nothing(self): # Should not raise error settings.post_init() - # Should not modify anything - assert hasattr(settings, "SETTINGS_NAMESPACE") - @pytest.mark.unit def test_post_init_custom_implementation(self): """Test custom post_init implementation.""" @@ -549,7 +508,6 @@ class TestIntegration: def test_full_workflow_with_templates(self): """Test complete workflow with template fields.""" params_obj = SettingsParameters.create( - namespace="template_workflow", settings_class=TemplateSettings, APP_NAME="myapp", ENVIRONMENT="production" @@ -562,7 +520,6 @@ def test_full_workflow_with_templates(self): # Extract parameters params = settings.extract_settings_parameters() - assert params.namespace == "template_workflow" # Hash should work hash_value = hash(settings) @@ -573,7 +530,6 @@ def test_full_workflow_with_updates(self): """Test complete workflow with updates.""" # Create initial settings params = SettingsParameters.create( - namespace="test_workflow", settings_class=TestSettings, TEST_VAL_1="initial" ) @@ -596,23 +552,23 @@ def test_get_settings_multiple_calls(self, isolated_settings_manager): """Test that get_settings works correctly across multiple calls.""" # First call - create settings params1 = SettingsParameters.create( - namespace="multi_call_test", settings_class=TestSettings, TEST_VAL_1="value1" ) settings1 = isolated_settings_manager.get_or_create_settings(params1) assert settings1.TEST_VAL_1 == "value1" - # Second call with different kwargs - returns cached instance + # Second call with different kwargs but same structural params params2 = SettingsParameters.create( - namespace="multi_call_test", settings_class=TestSettings, TEST_VAL_1="value2" ) settings2 = isolated_settings_manager.get_or_create_settings(params2) - # Should be same instance (cache key based on structural params) - assert settings1 is settings2 + # Cache should have only one entry (same structural params) + assert len(isolated_settings_manager.settings_object_cache) == 1 + # But returned values reflect the kwargs + assert settings2.TEST_VAL_1 == "value2" class TestEdgeCases: @@ -632,7 +588,6 @@ def test_hash_consistency(self): """Test that hash is consistent across multiple calls.""" settings = TestSettings( settings_parameters=SettingsParameters.create( - namespace="hash_test", settings_class=TestSettings ) ) @@ -659,7 +614,6 @@ def test_update_with_mixed_valid_invalid_attributes(self): def test_extract_parameters_idempotent(self): """Test that extract_settings_parameters is idempotent.""" original_params = SettingsParameters.create( - namespace="idempotent_test", settings_class=TestSettings, TEST_VAL_1="value1" ) @@ -669,6 +623,5 @@ def test_extract_parameters_idempotent(self): extracted2 = settings.extract_settings_parameters() # Should produce equivalent parameters - assert extracted1.namespace == extracted2.namespace assert extracted1.settings_class == extracted2.settings_class assert extracted1.kwargs == extracted2.kwargs diff --git a/tests/test_settings_manager.py b/tests/test_settings_manager.py index e649b8e..7144d38 100644 --- a/tests/test_settings_manager.py +++ b/tests/test_settings_manager.py @@ -3,7 +3,7 @@ Tests cover: - Settings creation and caching -- Namespace initialization checks +- Initialization checks - Settings retrieval - Runtime override application - MountainAshBaseSettings and non-MountainAshBaseSettings paths @@ -39,21 +39,19 @@ def test_get_settings_manager_returns_singleton(self): assert manager1 is manager2 -class TestIsNamespaceInitialised: - """Test is_namespace_initialised method.""" +class TestIsInitialised: + """Test is_initialised method.""" - def test_returns_false_for_new_namespace(self, isolated_settings_manager): - """Test that new namespace returns False.""" + def test_returns_false_for_new_params(self, isolated_settings_manager): + """Test that new params returns False.""" params = SettingsParameters.create( - namespace="new_namespace", settings_class=TestSettings ) - assert isolated_settings_manager.is_namespace_initialised(params) is False + assert isolated_settings_manager.is_initialised(params) is False def test_returns_true_after_initialization(self, isolated_settings_manager): - """Test that initialized namespace returns True.""" + """Test that initialized params returns True.""" params = SettingsParameters.create( - namespace="initialized_namespace", settings_class=TestSettings ) @@ -61,16 +59,14 @@ def test_returns_true_after_initialization(self, isolated_settings_manager): isolated_settings_manager.get_or_create_settings(params) # Should now be initialized - assert isolated_settings_manager.is_namespace_initialised(params) is True + assert isolated_settings_manager.is_initialised(params) is True def test_uses_hash_for_cache_key(self, isolated_settings_manager): """Test that cache key is based on SettingsParameters hash.""" params1 = SettingsParameters.create( - namespace="test", settings_class=TestSettings ) params2 = SettingsParameters.create( - namespace="test", settings_class=TestSettings ) @@ -78,7 +74,7 @@ def test_uses_hash_for_cache_key(self, isolated_settings_manager): isolated_settings_manager.get_or_create_settings(params1) # params2 has same hash, should also be initialized - assert isolated_settings_manager.is_namespace_initialised(params2) is True + assert isolated_settings_manager.is_initialised(params2) is True class TestGetOrCreateSettings: @@ -88,7 +84,6 @@ class TestGetOrCreateSettings: def test_creates_new_settings_for_first_call(self, isolated_settings_manager): """Test that first call creates new settings instance.""" params = SettingsParameters.create( - namespace="first_call", settings_class=TestSettings, TEST_VAL_1="value1" ) @@ -97,14 +92,12 @@ def test_creates_new_settings_for_first_call(self, isolated_settings_manager): assert settings is not None assert isinstance(settings, TestSettings) - assert settings.SETTINGS_NAMESPACE == "first_call" assert settings.TEST_VAL_1 == "value1" @pytest.mark.unit def test_returns_cached_settings_for_second_call(self, isolated_settings_manager): """Test that second call returns cached instance.""" params = SettingsParameters.create( - namespace="cached_test", settings_class=TestSettings ) @@ -120,7 +113,6 @@ def test_returns_cached_settings_for_second_call(self, isolated_settings_manager def test_raises_error_if_settings_class_missing(self, isolated_settings_manager): """Test that missing settings_class raises ValueError.""" params = SettingsParameters.create( - namespace="no_class", settings_class=None ) @@ -131,7 +123,6 @@ def test_raises_error_if_settings_class_missing(self, isolated_settings_manager) def test_creates_mountainash_base_settings_subclass(self, isolated_settings_manager): """Test MountainAshBaseSettings subclass creation path.""" params = SettingsParameters.create( - namespace="mountainash_test", settings_class=TestSettings, TEST_VAL_1="mountainash_value" ) @@ -145,8 +136,8 @@ def test_creates_mountainash_base_settings_subclass(self, isolated_settings_mana def test_creates_non_mountainash_settings_with_kwargs(self, isolated_settings_manager): """Test non-MountainAshBaseSettings class creation with kwargs.""" params = SettingsParameters.create( - namespace="non_mountainash_with_kwargs", settings_class=MockBaseSettings, + env_prefix="NON_MA_WITH_KWARGS_", test_field="custom_value", test_int=100 ) @@ -161,8 +152,8 @@ def test_creates_non_mountainash_settings_with_kwargs(self, isolated_settings_ma def test_creates_non_mountainash_settings_without_kwargs(self, isolated_settings_manager): """Test non-MountainAshBaseSettings class creation without kwargs.""" params = SettingsParameters.create( - namespace="non_mountainash_no_kwargs", - settings_class=MockBaseSettings + settings_class=MockBaseSettings, + env_prefix="NON_MA_NO_KWARGS_" ) settings = isolated_settings_manager.get_or_create_settings(params) @@ -173,25 +164,25 @@ def test_creates_non_mountainash_settings_without_kwargs(self, isolated_settings assert settings.test_int == 42 @pytest.mark.unit - def test_different_namespaces_create_different_settings(self, isolated_settings_manager): - """Test that different namespaces create separate settings instances.""" + def test_different_env_prefixes_create_different_settings(self, isolated_settings_manager): + """Test that different env_prefix values create separate settings instances.""" params1 = SettingsParameters.create( - namespace="namespace1", settings_class=TestSettings, - TEST_VAL_1="value_ns1" + env_prefix="PREFIX1_", + TEST_VAL_1="value_p1" ) params2 = SettingsParameters.create( - namespace="namespace2", settings_class=TestSettings, - TEST_VAL_1="value_ns2" + env_prefix="PREFIX2_", + TEST_VAL_1="value_p2" ) settings1 = isolated_settings_manager.get_or_create_settings(params1) settings2 = isolated_settings_manager.get_or_create_settings(params2) assert settings1 is not settings2 - assert settings1.TEST_VAL_1 == "value_ns1" - assert settings2.TEST_VAL_1 == "value_ns2" + assert settings1.TEST_VAL_1 == "value_p1" + assert settings2.TEST_VAL_1 == "value_p2" class TestGetSettingsObject: @@ -201,7 +192,6 @@ class TestGetSettingsObject: def test_retrieves_cached_settings(self, isolated_settings_manager): """Test retrieving settings from cache.""" params = SettingsParameters.create( - namespace="retrieve_test", settings_class=TestSettings ) @@ -217,40 +207,58 @@ def test_retrieves_cached_settings(self, isolated_settings_manager): def test_raises_error_for_non_mountainash_settings(self, isolated_settings_manager): """Test that non-MountainAshBaseSettings in cache raises ValueError.""" params = SettingsParameters.create( - namespace="non_mountainash_error", - settings_class=MockBaseSettings + settings_class=MockBaseSettings, + env_prefix="NON_MA_ERR_" ) # Manually add non-MountainAshBaseSettings to cache isolated_settings_manager.settings_object_cache[params] = MockBaseSettings() - with pytest.raises(ValueError, match="is not an MountainAshBaseSettings object"): + with pytest.raises(ValueError, match="is not a MountainAshBaseSettings object"): isolated_settings_manager.get_settings_object(params) @pytest.mark.unit def test_applies_runtime_override_kwargs(self, isolated_settings_manager): - """Test that runtime override kwargs are applied.""" - # Create settings without override + """Test that runtime override kwargs are applied to a copy, not the cached instance.""" params_create = SettingsParameters.create( - namespace="override_test", settings_class=TestSettings, TEST_VAL_1="original_value" ) created_settings = isolated_settings_manager.get_or_create_settings(params_create) assert created_settings.TEST_VAL_1 == "original_value" - # Retrieve with override kwargs params_override = SettingsParameters.create( - namespace="override_test", settings_class=TestSettings, TEST_VAL_1="overridden_value" ) - retrieved_settings = isolated_settings_manager.get_settings_object(params_override) - # Note: This tests current behavior - kwargs update the cached instance + # Retrieved copy has the override + assert retrieved_settings.TEST_VAL_1 == "overridden_value" + # Original cached instance is untouched + assert created_settings.TEST_VAL_1 == "original_value" + + @pytest.mark.unit + def test_runtime_overrides_do_not_mutate_cached_instance(self, isolated_settings_manager): + """Test that runtime override kwargs do NOT mutate the cached instance.""" + params_create = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="original_value" + ) + created_settings = isolated_settings_manager.get_or_create_settings(params_create) + assert created_settings.TEST_VAL_1 == "original_value" + + params_override = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="overridden_value" + ) + retrieved_settings = isolated_settings_manager.get_settings_object(params_override) assert retrieved_settings.TEST_VAL_1 == "overridden_value" + # The CACHED instance must NOT have been mutated + cached_directly = isolated_settings_manager.settings_object_cache[params_create] + assert cached_directly.TEST_VAL_1 == "original_value" + class TestCacheBehavior: """Test caching behavior and cache key logic.""" @@ -258,14 +266,12 @@ class TestCacheBehavior: @pytest.mark.unit def test_cache_key_based_on_structural_params(self, isolated_settings_manager): """Test that cache key is based on structural parameters only.""" - # Same structural params (namespace, class) but different kwargs + # Same structural params (class) but different kwargs params1 = SettingsParameters.create( - namespace="cache_test", settings_class=TestSettings, TEST_VAL_1="value1" ) params2 = SettingsParameters.create( - namespace="cache_test", settings_class=TestSettings, TEST_VAL_1="value2" ) @@ -277,16 +283,20 @@ def test_cache_key_based_on_structural_params(self, isolated_settings_manager): settings1 = isolated_settings_manager.get_or_create_settings(params1) # Second call with different kwargs but same structural params - # Should return cached instance + # Should return from cache (as a copy since override kwargs differ) settings2 = isolated_settings_manager.get_or_create_settings(params2) - assert settings1 is settings2 + # Both resolve to the same cache entry (same structural hash) + assert len(isolated_settings_manager.settings_object_cache) == 1 + # But the returned object has the override applied + assert settings2.TEST_VAL_1 == "value2" + # Original cached instance is untouched + assert settings1.TEST_VAL_1 == "value1" @pytest.mark.unit def test_cache_stores_by_settings_parameters(self, isolated_settings_manager): """Test that cache uses SettingsParameters as key.""" params = SettingsParameters.create( - namespace="namespace_key_test", settings_class=TestSettings ) @@ -301,16 +311,16 @@ def test_cache_stores_by_settings_parameters(self, isolated_settings_manager): def test_multiple_settings_in_cache(self, isolated_settings_manager): """Test that cache can hold multiple settings instances.""" params1 = SettingsParameters.create( - namespace="multi1", - settings_class=TestSettings + settings_class=TestSettings, + env_prefix="MULTI1_" ) params2 = SettingsParameters.create( - namespace="multi2", - settings_class=TestSettings + settings_class=TestSettings, + env_prefix="MULTI2_" ) params3 = SettingsParameters.create( - namespace="multi3", - settings_class=TestSettings + settings_class=TestSettings, + env_prefix="MULTI3_" ) settings1 = isolated_settings_manager.get_or_create_settings(params1) @@ -318,9 +328,9 @@ def test_multiple_settings_in_cache(self, isolated_settings_manager): settings3 = isolated_settings_manager.get_or_create_settings(params3) # All should be in cache - assert isolated_settings_manager.is_namespace_initialised(params1) - assert isolated_settings_manager.is_namespace_initialised(params2) - assert isolated_settings_manager.is_namespace_initialised(params3) + assert isolated_settings_manager.is_initialised(params1) + assert isolated_settings_manager.is_initialised(params2) + assert isolated_settings_manager.is_initialised(params3) # All should be different instances assert settings1 is not settings2 @@ -336,28 +346,30 @@ def test_full_workflow_create_retrieve_reuse(self, isolated_settings_manager): """Test complete workflow: create, retrieve, reuse.""" # Step 1: Create new settings params = SettingsParameters.create( - namespace="workflow_test", settings_class=TestSettings, TEST_VAL_1="initial_value" ) # Should not be initialized yet - assert not isolated_settings_manager.is_namespace_initialised(params) + assert not isolated_settings_manager.is_initialised(params) # Create settings settings1 = isolated_settings_manager.get_or_create_settings(params) assert settings1.TEST_VAL_1 == "initial_value" # Should now be initialized - assert isolated_settings_manager.is_namespace_initialised(params) + assert isolated_settings_manager.is_initialised(params) - # Step 2: Retrieve cached settings + # Step 2: Retrieve cached settings (returns copy when kwargs present) settings2 = isolated_settings_manager.get_or_create_settings(params) - assert settings2 is settings1 + assert settings2.TEST_VAL_1 == "initial_value" - # Step 3: Get settings object directly + # Step 3: Get settings object directly (returns copy when kwargs present) settings3 = isolated_settings_manager.get_settings_object(params) - assert settings3 is settings1 + assert settings3.TEST_VAL_1 == "initial_value" + + # Cache should still have only one entry + assert len(isolated_settings_manager.settings_object_cache) == 1 @pytest.mark.integration def test_with_config_files(self, isolated_settings_manager, temp_yaml_file): @@ -365,7 +377,6 @@ def test_with_config_files(self, isolated_settings_manager, temp_yaml_file): from mountainash_settings.settings.app.app_settings import AppSettings params = SettingsParameters.create( - namespace="config_file_test", settings_class=AppSettings, config_files=temp_yaml_file ) @@ -388,15 +399,14 @@ def test_validate_config_files_exist_raises_error(self, settings_manager): ) @pytest.mark.edge_case - def test_none_namespace_handled_correctly(self, isolated_settings_manager): - """Test that None namespace is handled correctly.""" + def test_none_params_handled_correctly(self, isolated_settings_manager): + """Test that params with no namespace are handled correctly.""" params = SettingsParameters.create( - namespace=None, settings_class=TestSettings ) settings = isolated_settings_manager.get_or_create_settings(params) - # Should create successfully with None namespace + # Should create successfully assert settings is not None assert isinstance(settings, TestSettings) diff --git a/tests/test_settings_parameters/test_merge_framework.py b/tests/test_settings_parameters/test_merge_framework.py index 3260005..0405d7b 100644 --- a/tests/test_settings_parameters/test_merge_framework.py +++ b/tests/test_settings_parameters/test_merge_framework.py @@ -1,842 +1,300 @@ """ -Comprehensive tests for merge_framework module. +Tests for SettingsParameters.merge() classmethod. Tests cover: -- Helper functions: _merge_simple, _merge_config_files, _merge_kwargs, _merge_settings_class -- SettingsParameterMerger.merge_with_object() -- SettingsParameterMerger.merge_with_params() -- FieldMergeUtils static methods -- Global merger instance -- Legacy compatibility classes -- ValidationError scenarios +- merge with None base raises ValueError +- merge with None other returns base +- config files combine and deduplicate +- kwargs second wins by default +- kwargs combine (different keys) +- env_prefix second wins +- secrets_dir second wins +- incompatible classes raise ValueError +- same class succeeds +- prioritise_base flag works +- both None kwargs produces None +- both None config_files produces None +- full integration workflow """ import pytest -from typing import Dict, Any - -from mountainash_settings.settings_parameters.merge_framework import ( - _merge_simple, - _merge_config_files, - _merge_kwargs, - _merge_settings_class, - SettingsParameterMerger, - FieldMergeUtils, - get_merger, - ValidationError, - MergePriority, - GenericMerger, -) + from mountainash_settings import SettingsParameters from fixtures.settings_classes import TestSettings, MockBaseSettings -class TestMergeSimple: - """Test _merge_simple helper function.""" - - @pytest.mark.unit - def test_merge_both_none_returns_none(self): - """Test that both None returns None.""" - result = _merge_simple(None, None) - assert result is None - - @pytest.mark.unit - def test_merge_first_none_returns_second(self): - """Test that first None returns second.""" - result = _merge_simple(None, "second") - assert result == "second" - - @pytest.mark.unit - def test_merge_second_none_returns_first(self): - """Test that second None returns first.""" - result = _merge_simple("first", None) - assert result == "first" - - @pytest.mark.unit - def test_merge_both_provided_returns_second(self): - """Test that second wins by default.""" - result = _merge_simple("first", "second") - assert result == "second" - - @pytest.mark.unit - def test_merge_first_wins_returns_first(self): - """Test that first_wins=True returns first.""" - result = _merge_simple("first", "second", first_wins=True) - assert result == "first" - - @pytest.mark.unit - def test_merge_first_wins_with_first_none(self): - """Test that first_wins with first=None returns second.""" - result = _merge_simple(None, "second", first_wins=True) - assert result == "second" - - @pytest.mark.unit - def test_merge_empty_string_behavior(self): - """Test behavior with empty strings (falsy values).""" - result = _merge_simple("", "second") - assert result == "second" +class TestMergeBasics: + """Test basic merge behavior.""" @pytest.mark.unit - def test_merge_zero_and_one(self): - """Test behavior with 0 and 1 (falsy/truthy values).""" - result = _merge_simple(0, 1) - assert result == 1 + def test_merge_none_base_raises_valueerror(self): + """Test that None base raises ValueError.""" + other = SettingsParameters.create(settings_class=TestSettings) + with pytest.raises(ValueError, match="Base SettingsParameters cannot be None"): + SettingsParameters.merge(None, other) @pytest.mark.unit - def test_merge_false_and_true(self): - """Test behavior with False and True.""" - result = _merge_simple(False, True) - assert result is True + def test_merge_none_other_returns_base(self): + """Test that None other returns base unchanged.""" + base = SettingsParameters.create(settings_class=TestSettings, env_prefix="BASE_") + result = SettingsParameters.merge(base, None) + assert result is base + assert result.env_prefix == "BASE_" class TestMergeConfigFiles: - """Test _merge_config_files helper function.""" - - @pytest.mark.unit - def test_merge_both_none_returns_none(self): - """Test that both None returns None.""" - result = _merge_config_files(None, None) - assert result is None - - @pytest.mark.unit - def test_merge_first_none_returns_second(self): - """Test that first None returns second.""" - result = _merge_config_files(None, ("config2.yaml",)) - assert result == ("config2.yaml",) - - @pytest.mark.unit - def test_merge_second_none_returns_first(self): - """Test that second None returns first.""" - result = _merge_config_files(("config1.yaml",), None) - assert result == ("config1.yaml",) - - @pytest.mark.unit - def test_merge_combines_and_deduplicates(self): - """Test that files are combined and deduplicated.""" - result = _merge_config_files( - ("config1.yaml", "config2.yaml"), - ("config2.yaml", "config3.yaml") - ) - # Should deduplicate config2.yaml and sort - assert result == ("config1.yaml", "config2.yaml", "config3.yaml") + """Test config file merging behavior.""" @pytest.mark.unit - def test_merge_deduplication_only(self): - """Test deduplication when files overlap.""" - result = _merge_config_files( - ("config.yaml", "config.yaml"), - ("config.yaml",) + def test_config_files_combine_and_deduplicate(self): + """Test that config files are combined and deduplicated.""" + base = SettingsParameters.create( + settings_class=TestSettings, + config_files=["config1.yaml", "config2.yaml"] ) - assert result == ("config.yaml",) - - @pytest.mark.unit - def test_merge_first_wins_returns_first(self): - """Test that first_wins=True returns first.""" - result = _merge_config_files( - ("config1.yaml",), - ("config2.yaml",), - first_wins=True + other = SettingsParameters.create( + settings_class=TestSettings, + config_files=["config2.yaml", "config3.yaml"] ) - assert result == ("config1.yaml",) + result = SettingsParameters.merge(base, other) + config_files_str = tuple(str(f) for f in result.config_files) + assert config_files_str == ("config1.yaml", "config2.yaml", "config3.yaml") @pytest.mark.unit - def test_merge_first_wins_with_first_none(self): - """Test that first_wins with first=None returns second.""" - result = _merge_config_files(None, ("config2.yaml",), first_wins=True) - assert result == ("config2.yaml",) + def test_both_none_config_files_produces_none(self): + """Test that both None config_files produces None.""" + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create(settings_class=TestSettings) + result = SettingsParameters.merge(base, other) + assert result.config_files is None @pytest.mark.unit - def test_merge_sorting_behavior(self): - """Test that merged files are sorted.""" - result = _merge_config_files( - ("z.yaml", "a.yaml"), - ("m.yaml",) + def test_config_files_prioritise_base(self): + """Test that prioritise_base returns base config files.""" + base = SettingsParameters.create( + settings_class=TestSettings, + config_files=["config1.yaml"] ) - assert result == ("a.yaml", "m.yaml", "z.yaml") - - @pytest.mark.unit - def test_merge_empty_tuples_returns_none(self): - """Test that empty tuples result in None.""" - result = _merge_config_files((), ()) - assert result is None + other = SettingsParameters.create( + settings_class=TestSettings, + config_files=["config2.yaml"] + ) + result = SettingsParameters.merge(base, other, prioritise_base=True) + config_files_str = tuple(str(f) for f in result.config_files) + assert config_files_str == ("config1.yaml",) class TestMergeKwargs: - """Test _merge_kwargs helper function.""" - - @pytest.mark.unit - def test_merge_both_none_returns_none(self): - """Test that both None returns None.""" - result = _merge_kwargs(None, None) - assert result is None - - @pytest.mark.unit - def test_merge_first_none_returns_second(self): - """Test that first None returns second.""" - result = _merge_kwargs(None, {"key": "value"}) - assert result == {"key": "value"} - - @pytest.mark.unit - def test_merge_second_none_returns_first(self): - """Test that second None returns first.""" - result = _merge_kwargs({"key": "value"}, None) - assert result == {"key": "value"} - - @pytest.mark.unit - def test_merge_combines_dicts(self): - """Test that dicts are combined with second taking precedence.""" - result = _merge_kwargs( - {"key1": "value1", "shared": "first"}, - {"key2": "value2", "shared": "second"} - ) - assert result == {"key1": "value1", "key2": "value2", "shared": "second"} + """Test kwargs merging behavior.""" @pytest.mark.unit - def test_merge_nested_kwargs_extraction(self): - """Test that nested 'kwargs' key is extracted.""" - result = _merge_kwargs( - {"key1": "value1"}, - {"kwargs": {"key2": "value2"}} + def test_kwargs_second_wins(self): + """Test that second kwargs take precedence for shared keys.""" + base = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="base_value" ) - # After merge, should extract the 'kwargs' nested dict - assert result == {"key2": "value2"} - - @pytest.mark.unit - def test_merge_first_wins_returns_first(self): - """Test that first_wins=True returns first.""" - result = _merge_kwargs( - {"key": "first"}, - {"key": "second"}, - first_wins=True + other = SettingsParameters.create( + settings_class=TestSettings, + TEST_VAL_1="other_value" ) - assert result == {"key": "first"} - - @pytest.mark.unit - def test_merge_first_wins_with_first_none(self): - """Test that first_wins with first=None returns second.""" - result = _merge_kwargs(None, {"key": "value"}, first_wins=True) - assert result == {"key": "value"} - - @pytest.mark.unit - def test_merge_empty_dicts_returns_none(self): - """Test that empty dicts result in None.""" - result = _merge_kwargs({}, {}) - assert result is None - - -class TestMergeSettingsClass: - """Test _merge_settings_class helper function.""" - - @pytest.mark.unit - def test_merge_both_none_returns_none(self): - """Test that both None returns None.""" - result = _merge_settings_class(None, None) - assert result is None - - @pytest.mark.unit - def test_merge_first_none_returns_second(self): - """Test that first None returns second.""" - result = _merge_settings_class(None, TestSettings) - assert result is TestSettings - - @pytest.mark.unit - def test_merge_second_none_returns_first(self): - """Test that second None returns first.""" - result = _merge_settings_class(TestSettings, None) - assert result is TestSettings - - @pytest.mark.unit - def test_merge_same_class_returns_class(self): - """Test that same class returns the class.""" - result = _merge_settings_class(TestSettings, TestSettings) - assert result is TestSettings - - @pytest.mark.unit - def test_merge_different_classes_raises_error(self): - """Test that different classes raise ValidationError.""" - with pytest.raises(ValidationError, match="Settings class must match"): - _merge_settings_class(TestSettings, MockBaseSettings) - - @pytest.mark.unit - def test_merge_first_wins_same_class(self): - """Test first_wins with same class.""" - result = _merge_settings_class(TestSettings, TestSettings, first_wins=True) - assert result is TestSettings - - @pytest.mark.unit - def test_merge_first_wins_with_first_none(self): - """Test that first_wins with first=None returns second.""" - result = _merge_settings_class(None, TestSettings, first_wins=True) - assert result is TestSettings - - -class TestSettingsParameterMergerObject: - """Test SettingsParameterMerger.merge_with_object method.""" - - @pytest.mark.unit - def test_merge_raises_error_if_base_none(self): - """Test that None base raises ValidationError.""" - merger = SettingsParameterMerger() - other = SettingsParameters.create(namespace="other", settings_class=TestSettings) - - with pytest.raises(ValidationError, match="Base SettingsParameters cannot be None"): - merger.merge_with_object(None, other) - - @pytest.mark.unit - def test_merge_with_none_other_returns_base(self): - """Test that None other returns base unchanged.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace="base", settings_class=TestSettings) - - result = merger.merge_with_object(base, None) - assert result.namespace == "base" - - @pytest.mark.unit - def test_merge_namespaces_second_wins(self): - """Test that second namespace wins by default.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace="base_ns", settings_class=TestSettings) - other = SettingsParameters.create(namespace="other_ns", settings_class=TestSettings) - - result = merger.merge_with_object(base, other) - assert result.namespace == "other_ns" - - @pytest.mark.unit - def test_merge_namespaces_first_wins(self): - """Test that first namespace wins with prioritise_base=True.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace="base_ns", settings_class=TestSettings) - other = SettingsParameters.create(namespace="other_ns", settings_class=TestSettings) - - result = merger.merge_with_object(base, other, prioritise_base=True) - assert result.namespace == "base_ns" + result = SettingsParameters.merge(base, other) + assert result.kwargs["TEST_VAL_1"] == "other_value" @pytest.mark.unit - def test_merge_config_files_combines(self): - """Test that config files are combined and deduplicated.""" - merger = SettingsParameterMerger() + def test_kwargs_combine_different_keys(self): + """Test that kwargs with different keys are combined.""" base = SettingsParameters.create( - namespace="test", settings_class=TestSettings, - config_files=["config1.yaml", "config2.yaml"] + TEST_VAL_1="base_value" ) other = SettingsParameters.create( - namespace="test", settings_class=TestSettings, - config_files=["config2.yaml", "config3.yaml"] + TEST_VAL_2="other_value" ) + result = SettingsParameters.merge(base, other) + assert result.kwargs["TEST_VAL_1"] == "base_value" + assert result.kwargs["TEST_VAL_2"] == "other_value" - result = merger.merge_with_object(base, other) - # Convert UPath to strings for comparison - config_files_str = tuple(str(f) for f in result.config_files) - assert config_files_str == ("config1.yaml", "config2.yaml", "config3.yaml") + @pytest.mark.unit + def test_both_none_kwargs_produces_none(self): + """Test that both None kwargs produces None.""" + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create(settings_class=TestSettings) + result = SettingsParameters.merge(base, other) + assert result.kwargs is None @pytest.mark.unit - def test_merge_kwargs_second_wins(self): - """Test that second kwargs take precedence.""" - merger = SettingsParameterMerger() + def test_kwargs_prioritise_base(self): + """Test that prioritise_base returns base kwargs.""" base = SettingsParameters.create( - namespace="test", settings_class=TestSettings, TEST_VAL_1="base_value" ) other = SettingsParameters.create( - namespace="test", settings_class=TestSettings, TEST_VAL_1="other_value" ) + result = SettingsParameters.merge(base, other, prioritise_base=True) + assert result.kwargs["TEST_VAL_1"] == "base_value" - result = merger.merge_with_object(base, other) - assert result.kwargs["TEST_VAL_1"] == "other_value" + +class TestMergeScalars: + """Test scalar field merging behavior.""" @pytest.mark.unit - def test_merge_env_prefix_second_wins(self): + def test_env_prefix_second_wins(self): """Test that second env_prefix wins by default.""" - merger = SettingsParameterMerger() base = SettingsParameters.create( - namespace="test", settings_class=TestSettings, env_prefix="BASE_" ) other = SettingsParameters.create( - namespace="test", settings_class=TestSettings, env_prefix="OTHER_" ) - - result = merger.merge_with_object(base, other) + result = SettingsParameters.merge(base, other) assert result.env_prefix == "OTHER_" @pytest.mark.unit - def test_merge_secrets_dir_second_wins(self): + def test_secrets_dir_second_wins(self): """Test that second secrets_dir wins by default.""" - merger = SettingsParameterMerger() base = SettingsParameters.create( - namespace="test", settings_class=TestSettings, secrets_dir="/base/secrets" ) other = SettingsParameters.create( - namespace="test", settings_class=TestSettings, secrets_dir="/other/secrets" ) - - result = merger.merge_with_object(base, other) + result = SettingsParameters.merge(base, other) assert result.secrets_dir == "/other/secrets" @pytest.mark.unit - def test_merge_namespace_none_fallback_to_default(self): - """Test that None namespace falls back to DEFAULT.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace=None, settings_class=TestSettings) - other = SettingsParameters.create(namespace=None, settings_class=TestSettings) - - result = merger.merge_with_object(base, other) - assert result.namespace == "DEFAULT" - - -class TestSettingsParameterMergerParams: - """Test SettingsParameterMerger.merge_with_params method.""" - - @pytest.mark.unit - def test_merge_raises_error_if_base_none(self): - """Test that None base raises ValidationError.""" - merger = SettingsParameterMerger() - - with pytest.raises(ValidationError, match="Base SettingsParameters cannot be None"): - merger.merge_with_params(None, namespace="test") - - @pytest.mark.unit - def test_merge_with_no_params_returns_base(self): - """Test that merging with no params returns base.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace="base", settings_class=TestSettings) - - result = merger.merge_with_params(base) - assert result.namespace == "base" - assert result.settings_class is TestSettings - - @pytest.mark.unit - def test_merge_namespace_param(self): - """Test merging with namespace parameter.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace="base", settings_class=TestSettings) - - result = merger.merge_with_params(base, namespace="new_namespace") - assert result.namespace == "new_namespace" - - @pytest.mark.unit - def test_merge_config_files_param(self): - """Test merging with config_files parameter.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create( - namespace="test", - settings_class=TestSettings, - config_files=["config1.yaml"] - ) - - result = merger.merge_with_params(base, config_files=["config2.yaml", "config3.yaml"]) - # Should combine and deduplicate - convert UPath to strings for comparison - config_files_str = set(str(f) for f in result.config_files) - assert config_files_str == {"config1.yaml", "config2.yaml", "config3.yaml"} - - @pytest.mark.unit - def test_merge_kwargs_param(self): - """Test merging with kwargs parameter.""" - merger = SettingsParameterMerger() + def test_env_prefix_prioritise_base(self): + """Test that prioritise_base returns base env_prefix.""" base = SettingsParameters.create( - namespace="test", settings_class=TestSettings, - TEST_VAL_1="base_value" + env_prefix="BASE_" ) - - result = merger.merge_with_params(base, kwargs={"TEST_VAL_2": "new_value"}) - assert result.kwargs["TEST_VAL_1"] == "base_value" - assert result.kwargs["TEST_VAL_2"] == "new_value" - - @pytest.mark.unit - def test_merge_env_prefix_param(self): - """Test merging with env_prefix parameter.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create( - namespace="test", + other = SettingsParameters.create( settings_class=TestSettings, - env_prefix="BASE_" + env_prefix="OTHER_" ) - - result = merger.merge_with_params(base, env_prefix="NEW_") - assert result.env_prefix == "NEW_" + result = SettingsParameters.merge(base, other, prioritise_base=True) + assert result.env_prefix == "BASE_" @pytest.mark.unit - def test_merge_secrets_dir_param(self): - """Test merging with secrets_dir parameter.""" - merger = SettingsParameterMerger() + def test_secrets_dir_prioritise_base(self): + """Test that prioritise_base returns base secrets_dir.""" base = SettingsParameters.create( - namespace="test", settings_class=TestSettings, secrets_dir="/base/secrets" ) - - result = merger.merge_with_params(base, secrets_dir="/new/secrets") - assert result.secrets_dir == "/new/secrets" - - @pytest.mark.unit - def test_merge_prioritise_base_true(self): - """Test that prioritise_base=True keeps base values.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create( - namespace="base_ns", + other = SettingsParameters.create( settings_class=TestSettings, - env_prefix="BASE_" - ) - - result = merger.merge_with_params( - base, - namespace="new_ns", - env_prefix="NEW_", - prioritise_base=True - ) - assert result.namespace == "base_ns" - assert result.env_prefix == "BASE_" - - @pytest.mark.unit - def test_merge_multiple_params_at_once(self): - """Test merging multiple parameters simultaneously.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace="base", settings_class=TestSettings) - - result = merger.merge_with_params( - base, - namespace="new_namespace", - config_files=["config.yaml"], - kwargs={"TEST_VAL_1": "value"}, - env_prefix="NEW_", - secrets_dir="/secrets" + secrets_dir="/other/secrets" ) - assert result.namespace == "new_namespace" - assert result.config_files == ("config.yaml",) - assert result.kwargs["TEST_VAL_1"] == "value" - assert result.env_prefix == "NEW_" - assert result.secrets_dir == "/secrets" - - @pytest.mark.unit - def test_merge_settings_class_preserved(self): - """Test that settings_class is preserved from base.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace="test", settings_class=TestSettings) - - result = merger.merge_with_params(base, namespace="new") - assert result.settings_class is TestSettings - + result = SettingsParameters.merge(base, other, prioritise_base=True) + assert result.secrets_dir == "/base/secrets" -class TestFieldMergeUtils: - """Test FieldMergeUtils static methods.""" - @pytest.mark.unit - def test_merge_namespaces_both_provided(self): - """Test merge_namespaces with both values.""" - result = FieldMergeUtils.merge_namespaces("first", "second") - assert result == "first" +class TestMergeSettingsClass: + """Test settings_class merging and validation.""" @pytest.mark.unit - def test_merge_namespaces_first_none(self): - """Test merge_namespaces with first None.""" - result = FieldMergeUtils.merge_namespaces(None, "second") - assert result == "second" + def test_incompatible_classes_raise_valueerror(self): + """Test that incompatible settings classes raise ValueError.""" + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create(settings_class=MockBaseSettings) + with pytest.raises(ValueError, match="Settings class must match"): + SettingsParameters.merge(base, other) @pytest.mark.unit - def test_merge_namespaces_both_none(self): - """Test merge_namespaces with both None defaults to DEFAULT.""" - result = FieldMergeUtils.merge_namespaces(None, None) - assert result == "DEFAULT" + def test_same_class_succeeds(self): + """Test that same class merges successfully.""" + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create(settings_class=TestSettings) + result = SettingsParameters.merge(base, other) + assert result.settings_class is TestSettings @pytest.mark.unit - def test_merge_env_prefixes_both_provided(self): - """Test merge_env_prefixes with both values.""" - result = FieldMergeUtils.merge_env_prefixes("FIRST_", "SECOND_") - assert result == "FIRST_" + def test_one_none_class_uses_other(self): + """Test that if one class is None, the other is used.""" + base = SettingsParameters.create(settings_class=TestSettings) + other = SettingsParameters.create() + result = SettingsParameters.merge(base, other) + assert result.settings_class is TestSettings - @pytest.mark.unit - def test_merge_env_prefixes_first_none(self): - """Test merge_env_prefixes with first None.""" - result = FieldMergeUtils.merge_env_prefixes(None, "SECOND_") - assert result == "SECOND_" - @pytest.mark.unit - def test_merge_env_prefixes_both_none(self): - """Test merge_env_prefixes with both None.""" - result = FieldMergeUtils.merge_env_prefixes(None, None) - assert result is None +class TestMergePrioritiseBase: + """Test the prioritise_base flag across all fields.""" @pytest.mark.unit - def test_merge_config_files_simple(self): - """Test merge_config_files_simple combines and deduplicates.""" - result = FieldMergeUtils.merge_config_files_simple( - ("config1.yaml", "config2.yaml"), - ("config2.yaml", "config3.yaml") + def test_prioritise_base_full(self): + """Test that prioritise_base=True keeps all base values.""" + base = SettingsParameters.create( + settings_class=TestSettings, + config_files=["base.yaml"], + env_prefix="BASE_", + secrets_dir="/base/secrets", + TEST_VAL_1="base_value" ) - assert result == ("config1.yaml", "config2.yaml", "config3.yaml") - - @pytest.mark.unit - def test_merge_config_files_simple_both_none(self): - """Test merge_config_files_simple with both None.""" - result = FieldMergeUtils.merge_config_files_simple(None, None) - assert result is None - - @pytest.mark.unit - def test_merge_kwargs_simple(self): - """Test merge_kwargs_simple with second taking precedence.""" - result = FieldMergeUtils.merge_kwargs_simple( - {"key1": "value1", "shared": "first"}, - {"key2": "value2", "shared": "second"} + other = SettingsParameters.create( + settings_class=TestSettings, + config_files=["other.yaml"], + env_prefix="OTHER_", + secrets_dir="/other/secrets", + TEST_VAL_1="other_value" ) - assert result == {"key1": "value1", "key2": "value2", "shared": "second"} - - @pytest.mark.unit - def test_merge_kwargs_simple_both_none(self): - """Test merge_kwargs_simple with both None.""" - result = FieldMergeUtils.merge_kwargs_simple(None, None) - assert result is None - - -class TestGlobalMerger: - """Test global merger instance.""" - - @pytest.mark.unit - def test_get_merger_returns_instance(self): - """Test that get_merger returns SettingsParameterMerger instance.""" - merger = get_merger() - assert isinstance(merger, SettingsParameterMerger) - - @pytest.mark.unit - def test_get_merger_returns_singleton(self): - """Test that get_merger returns same instance.""" - merger1 = get_merger() - merger2 = get_merger() - assert merger1 is merger2 - - @pytest.mark.unit - def test_global_merger_functional(self): - """Test that global merger works for merging.""" - merger = get_merger() - base = SettingsParameters.create(namespace="base", settings_class=TestSettings) - other = SettingsParameters.create(namespace="other", settings_class=TestSettings) - - result = merger.merge_with_object(base, other) - assert result.namespace == "other" - - -class TestLegacyCompatibility: - """Test legacy compatibility classes and enums.""" - - @pytest.mark.unit - def test_merge_priority_enum_exists(self): - """Test that MergePriority enum exists with expected values.""" - assert hasattr(MergePriority, "FIRST_WINS") - assert hasattr(MergePriority, "SECOND_WINS") - assert hasattr(MergePriority, "COMBINE") - assert MergePriority.FIRST_WINS == "first_wins" - assert MergePriority.SECOND_WINS == "second_wins" - assert MergePriority.COMBINE == "combine" - - @pytest.mark.unit - def test_generic_merger_instantiation(self): - """Test that GenericMerger can be instantiated.""" - merger = GenericMerger() - assert isinstance(merger, GenericMerger) - - @pytest.mark.unit - def test_generic_merger_merge_field(self): - """Test GenericMerger.merge_field method.""" - merger = GenericMerger() - result = merger.merge_field("test_field", "first", "second") - assert result == "second" - - @pytest.mark.unit - def test_generic_merger_merge_field_prioritise_first(self): - """Test GenericMerger.merge_field with prioritise_first=True.""" - merger = GenericMerger() - result = merger.merge_field("test_field", "first", "second", prioritise_first=True) - assert result == "first" - - @pytest.mark.unit - def test_generic_merger_merge_fields(self): - """Test GenericMerger.merge_fields method.""" - merger = GenericMerger() - field_specs = { - "field1": {"first": "value1", "second": "value2"}, - "field2": {"first": "value3", "second": "value4"} - } - result = merger.merge_fields(field_specs) - assert result["field1"] == "value2" - assert result["field2"] == "value4" - - @pytest.mark.unit - def test_generic_merger_merge_fields_prioritise_first(self): - """Test GenericMerger.merge_fields with prioritise_first=True.""" - merger = GenericMerger() - field_specs = { - "field1": {"first": "value1", "second": "value2"}, - "field2": {"first": "value3", "second": "value4"} - } - result = merger.merge_fields(field_specs, prioritise_first=True) - assert result["field1"] == "value1" - assert result["field2"] == "value3" + result = SettingsParameters.merge(base, other, prioritise_base=True) + assert result.env_prefix == "BASE_" + assert result.secrets_dir == "/base/secrets" + assert result.kwargs["TEST_VAL_1"] == "base_value" + config_files_str = tuple(str(f) for f in result.config_files) + assert config_files_str == ("base.yaml",) -class TestIntegration: - """Integration tests for merge_framework.""" +class TestMergeIntegration: + """Integration tests for merge workflow.""" @pytest.mark.integration - def test_full_merge_workflow(self): + def test_full_integration_workflow(self): """Test complete merge workflow with multiple operations.""" - merger = get_merger() - # Create base parameters base = SettingsParameters.create( - namespace="base", settings_class=TestSettings, config_files=["config1.yaml"], env_prefix="BASE_", TEST_VAL_1="base_value" ) - # Merge with object + # Merge with another set of parameters other = SettingsParameters.create( - namespace="other", settings_class=TestSettings, config_files=["config2.yaml"], TEST_VAL_2="other_value" ) - merged_obj = merger.merge_with_object(base, other) + merged = SettingsParameters.merge(base, other) # Verify merged result - assert merged_obj.namespace == "other" - # Convert UPath to strings for comparison - config_files_str = set(str(f) for f in merged_obj.config_files) + config_files_str = set(str(f) for f in merged.config_files) assert config_files_str == {"config1.yaml", "config2.yaml"} - assert merged_obj.kwargs["TEST_VAL_1"] == "base_value" - assert merged_obj.kwargs["TEST_VAL_2"] == "other_value" + assert merged.kwargs["TEST_VAL_1"] == "base_value" + assert merged.kwargs["TEST_VAL_2"] == "other_value" + assert merged.env_prefix == "BASE_" - # Merge again with params - final = merger.merge_with_params( - merged_obj, - namespace="final", + # Merge again with a third set + third = SettingsParameters.create( + settings_class=TestSettings, config_files=["config3.yaml"], - kwargs={"TEST_VAL_3": "final_value"} + TEST_VAL_3="final_value" ) + final = SettingsParameters.merge(merged, third) # Verify final result - assert final.namespace == "final" - # Convert UPath to strings for comparison final_config_files_str = set(str(f) for f in final.config_files) assert final_config_files_str == {"config1.yaml", "config2.yaml", "config3.yaml"} assert final.kwargs["TEST_VAL_1"] == "base_value" assert final.kwargs["TEST_VAL_2"] == "other_value" assert final.kwargs["TEST_VAL_3"] == "final_value" - - @pytest.mark.integration - def test_prioritise_base_workflow(self): - """Test merge workflow with prioritise_base=True.""" - merger = get_merger() - - base = SettingsParameters.create( - namespace="base", - settings_class=TestSettings, - env_prefix="BASE_", - TEST_VAL_1="base_value" - ) - - other = SettingsParameters.create( - namespace="other", - settings_class=TestSettings, - env_prefix="OTHER_", - TEST_VAL_1="other_value" - ) - - # Merge with prioritise_base=True - result = merger.merge_with_object(base, other, prioritise_base=True) - - # Base values should win - assert result.namespace == "base" - assert result.env_prefix == "BASE_" - assert result.kwargs["TEST_VAL_1"] == "base_value" - - @pytest.mark.integration - def test_field_merge_utils_integration(self): - """Test FieldMergeUtils with realistic data.""" - # Merge namespaces - ns = FieldMergeUtils.merge_namespaces("production", "staging") - assert ns == "production" - - # Merge config files - config_files = FieldMergeUtils.merge_config_files_simple( - ("base.yaml", "prod.yaml"), - ("prod.yaml", "override.yaml") - ) - assert config_files == ("base.yaml", "override.yaml", "prod.yaml") - - # Merge kwargs - kwargs = FieldMergeUtils.merge_kwargs_simple( - {"DEBUG": False, "LOG_LEVEL": "INFO"}, - {"LOG_LEVEL": "DEBUG", "FEATURE_FLAG": True} - ) - assert kwargs == {"DEBUG": False, "LOG_LEVEL": "DEBUG", "FEATURE_FLAG": True} - - -class TestEdgeCases: - """Test edge cases and error conditions.""" - - @pytest.mark.edge_case - def test_merge_incompatible_settings_classes(self): - """Test that merging incompatible settings classes raises error.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create(namespace="test", settings_class=TestSettings) - other = SettingsParameters.create(namespace="test", settings_class=MockBaseSettings) - - with pytest.raises(ValidationError, match="Settings class must match"): - merger.merge_with_object(base, other) - - @pytest.mark.edge_case - def test_merge_with_empty_config_files(self): - """Test merging with empty config file tuples.""" - merger = SettingsParameterMerger() - base = SettingsParameters.create( - namespace="test", - settings_class=TestSettings, - config_files=[] - ) - other = SettingsParameters.create( - namespace="test", - settings_class=TestSettings, - config_files=[] - ) - - result = merger.merge_with_object(base, other) - assert result.config_files is None - - @pytest.mark.edge_case - def test_merge_with_empty_kwargs(self): - """Test merging with empty kwargs dicts.""" - result = _merge_kwargs({}, {}) - assert result is None - - @pytest.mark.edge_case - def test_merge_config_files_with_duplicates(self): - """Test merging config files with many duplicates.""" - result = _merge_config_files( - ("file.yaml", "file.yaml", "file.yaml"), - ("file.yaml", "file.yaml") - ) - assert result == ("file.yaml",) - - @pytest.mark.edge_case - def test_merge_kwargs_nested_extraction(self): - """Test that nested kwargs key is properly extracted.""" - result = _merge_kwargs( - {"outer_key": "value"}, - {"kwargs": {"inner_key": "inner_value"}} - ) - # Should extract the nested kwargs dict - assert result == {"inner_key": "inner_value"} - assert "outer_key" not in result diff --git a/tests/test_settings_parameters/test_settings_parameters.py b/tests/test_settings_parameters/test_settings_parameters.py index 0336683..aee66e1 100644 --- a/tests/test_settings_parameters/test_settings_parameters.py +++ b/tests/test_settings_parameters/test_settings_parameters.py @@ -17,7 +17,6 @@ class TestSettingsParameters: def test_initialization_with_defaults_succeeds(self): params = SettingsParameters() - assert params.namespace is None assert params.config_files is None assert params.settings_class is None assert params.env_prefix is None @@ -27,17 +26,15 @@ def test_initialization_with_defaults_succeeds(self): def test_initialization_with_all_parameters_succeeds(self): config_files = ["config.yaml"] kwargs = {"DEBUG": True} - + params = SettingsParameters( - namespace="test", config_files=config_files, settings_class=MockSettings, env_prefix="TEST_", secrets_dir="/secrets", kwargs=kwargs ) - - assert params.namespace == "test" + assert params.config_files == config_files assert params.settings_class == MockSettings assert params.env_prefix == "TEST_" @@ -47,23 +44,22 @@ def test_initialization_with_all_parameters_succeeds(self): def test_dataclass_is_frozen(self): params = SettingsParameters() with pytest.raises(FrozenInstanceError): - params.namespace = "new_namespace" + params.config_files = ("new_config.yaml",) def test_hash_returns_consistent_value(self): - params1 = SettingsParameters(namespace="test", settings_class=MockSettings) - params2 = SettingsParameters(namespace="test", settings_class=MockSettings) - + params1 = SettingsParameters(settings_class=MockSettings) + params2 = SettingsParameters(settings_class=MockSettings) + assert hash(params1) == hash(params2) def test_hash_different_for_different_params(self): - params1 = SettingsParameters(namespace="test1") - params2 = SettingsParameters(namespace="test2") - + params1 = SettingsParameters(env_prefix="PREFIX1_") + params2 = SettingsParameters(env_prefix="PREFIX2_") + assert hash(params1) != hash(params2) def test_create_with_all_parameters_succeeds(self): params = SettingsParameters.create( - namespace="test", config_files="config.yaml", settings_class=MockSettings, env_prefix="TEST_", @@ -71,8 +67,7 @@ def test_create_with_all_parameters_succeeds(self): DEBUG=True, VERBOSE=False ) - - assert params.namespace == "test" + assert isinstance(params.config_files, tuple) assert params.settings_class == MockSettings assert params.env_prefix == "TEST_" @@ -92,31 +87,21 @@ def test_create_with_list_config_files_converts_to_tuple(self): assert len(params.config_files) == 2 def test_create_with_no_kwargs_sets_kwargs_to_none(self): - params = SettingsParameters.create(namespace="test") + params = SettingsParameters.create() assert params.kwargs is None - def test_init_namespace_returns_default_for_none(self): - result = SettingsParameters._init_namespace(None) - assert result == "DEFAULT" - - def test_init_namespace_returns_provided_value(self): - result = SettingsParameters._init_namespace("custom") - assert result == "custom" - def test_to_dict_with_all_fields_populated(self): kwargs = {"DEBUG": True, "VERBOSE": False} params = SettingsParameters( - namespace="test", config_files=("config.yaml",), settings_class=MockSettings, env_prefix="TEST_", secrets_dir="/secrets", kwargs=kwargs ) - + result = params.to_dict() - - assert result["namespace"] == "test" + assert result["config_files"] == ["config.yaml"] assert result["kwargs"] == kwargs assert result["settings_class"] == MockSettings @@ -126,8 +111,7 @@ def test_to_dict_with_all_fields_populated(self): def test_to_dict_with_none_values(self): params = SettingsParameters() result = params.to_dict() - - assert result["namespace"] is None + assert result["config_files"] is None assert result["kwargs"] is None assert result["settings_class"] is None @@ -137,7 +121,7 @@ def test_to_dict_with_none_values(self): def test_get_settings_kwarg_names_with_mock_settings(self): params = SettingsParameters(settings_class=MockSettings) result = params._get_settings_kwarg_names() - + expected_fields = {"field1", "field2", "field3"} assert result == expected_fields @@ -149,14 +133,14 @@ def test_get_settings_kwarg_names_with_none_settings_class(self): def test_get_settings_kwarg_names_with_provided_class(self): params = SettingsParameters() result = params._get_settings_kwarg_names(MockSettings) - + expected_fields = {"field1", "field2", "field3"} assert result == expected_fields def test_get_valid_kwarg_names_includes_reserved_pydantic_kwargs(self): params = SettingsParameters(settings_class=MockSettings) result = params._get_valid_kwarg_names() - + assert "field1" in result assert "field2" in result assert "field3" in result @@ -170,10 +154,10 @@ def test_get_attribute_settings_kwargs_filters_correctly(self): "_env_prefix": "TEST_", "invalid_field": "should_be_filtered" } - + params = SettingsParameters(settings_class=MockSettings, kwargs=kwargs) result = params.get_attribute_settings_kwargs() - + assert "field1" in result assert "field2" in result assert "_env_prefix" in result @@ -186,10 +170,10 @@ def test_get_pydantic_settings_kwargs_returns_only_pydantic_kwargs(self): "_case_sensitive": True, "custom_field": "value" } - + params = SettingsParameters(kwargs=kwargs) result = params.get_pydantic_settings_kwargs() - + assert "_env_prefix" in result assert "_case_sensitive" in result assert "field1" not in result @@ -202,10 +186,10 @@ def test_get_pydantic_modelconfig_kwargs_returns_only_modelconfig_kwargs(self): "field1": "value1", "_env_prefix": "TEST_" } - + params = SettingsParameters(kwargs=kwargs) result = params.get_pydantic_modelconfig_kwargs() - + assert "extra" in result assert "arbitrary_types_allowed" in result assert "field1" not in result @@ -217,13 +201,13 @@ def test_get_all_kwargs_returns_all_kwargs(self): "_env_prefix": "TEST_", "custom": "value" } - + params = SettingsParameters(kwargs=kwargs) result = params.get_all_kwargs() - + assert result == kwargs def test_get_all_kwargs_returns_empty_dict_when_none(self): params = SettingsParameters() result = params.get_all_kwargs() - assert result == {} \ No newline at end of file + assert result == {} diff --git a/tests/test_settings_parameters/test_settings_parameters_coverage.py b/tests/test_settings_parameters/test_settings_parameters_coverage.py index 464ffa3..d40e6d2 100644 --- a/tests/test_settings_parameters/test_settings_parameters_coverage.py +++ b/tests/test_settings_parameters/test_settings_parameters_coverage.py @@ -35,7 +35,6 @@ class TestEquality: def test_eq_with_non_settings_parameters_returns_false(self): """Test equality with non-SettingsParameters object returns False.""" params = SettingsParameters.create( - namespace="test", settings_class=TestSettings ) @@ -43,20 +42,18 @@ def test_eq_with_non_settings_parameters_returns_false(self): assert params != "string" assert params != 123 assert params != None - assert params != {"namespace": "test"} + assert params != {"settings_class": TestSettings} assert params != ["test"] @pytest.mark.unit def test_eq_with_identical_structural_params(self): """Test equality with identical structural parameters.""" params1 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=["config.yaml"], env_prefix="TEST_" ) params2 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=["config.yaml"], env_prefix="TEST_" @@ -69,12 +66,10 @@ def test_eq_with_identical_structural_params(self): def test_eq_ignores_kwargs_differences(self): """Test that equality ignores kwargs (runtime parameters).""" params1 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, VALUE="value1" ) params2 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, VALUE="value2" ) @@ -84,29 +79,18 @@ def test_eq_ignores_kwargs_differences(self): assert hash(params1) == hash(params2) @pytest.mark.unit - def test_eq_ignores_secrets_dir_differences(self): - """Test that equality ignores secrets_dir (runtime parameter).""" + def test_eq_differs_on_secrets_dir(self): + """Test that different secrets_dir values produce inequality (structural param).""" params1 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, secrets_dir="/secrets1" ) params2 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, secrets_dir="/secrets2" ) - # Should be equal despite different secrets_dir - assert params1 == params2 - assert hash(params1) == hash(params2) - - @pytest.mark.unit - def test_eq_differs_on_namespace(self): - """Test that different namespaces produce inequality.""" - params1 = SettingsParameters.create(namespace="test1", settings_class=TestSettings) - params2 = SettingsParameters.create(namespace="test2", settings_class=TestSettings) - + # secrets_dir is structural -- different values should NOT be equal assert params1 != params2 assert hash(params1) != hash(params2) @@ -114,12 +98,10 @@ def test_eq_differs_on_namespace(self): def test_eq_differs_on_config_files(self): """Test that different config files produce inequality.""" params1 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=["config1.yaml"] ) params2 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=["config2.yaml"] ) @@ -130,8 +112,8 @@ def test_eq_differs_on_config_files(self): @pytest.mark.unit def test_eq_differs_on_settings_class(self): """Test that different settings classes produce inequality.""" - params1 = SettingsParameters.create(namespace="test", settings_class=TestSettings) - params2 = SettingsParameters.create(namespace="test", settings_class=SimpleSettings) + params1 = SettingsParameters.create(settings_class=TestSettings) + params2 = SettingsParameters.create(settings_class=SimpleSettings) assert params1 != params2 assert hash(params1) != hash(params2) @@ -140,12 +122,10 @@ def test_eq_differs_on_settings_class(self): def test_eq_differs_on_env_prefix(self): """Test that different env_prefix values produce inequality.""" params1 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, env_prefix="PREFIX1_" ) params2 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, env_prefix="PREFIX2_" ) @@ -160,9 +140,7 @@ class TestGetSettings: @pytest.mark.unit def test_get_settings_raises_error_without_settings_class(self): """Test that get_settings raises ValueError when settings_class is None.""" - params = SettingsParameters.create( - namespace="test_no_class" - ) + params = SettingsParameters.create() with pytest.raises(ValueError, match="Settings class is required to get settings"): params.get_settings() @@ -171,7 +149,6 @@ def test_get_settings_raises_error_without_settings_class(self): def test_get_settings_with_settings_class(self, isolated_settings_manager): """Test that get_settings works with settings_class provided.""" params = SettingsParameters.create( - namespace="test_with_class", settings_class=SimpleSettings, VALUE="custom_value" ) @@ -185,7 +162,6 @@ def test_get_settings_with_settings_class(self, isolated_settings_manager): def test_get_settings_with_additional_kwargs(self, isolated_settings_manager): """Test get_settings with additional kwargs passed.""" params = SettingsParameters.create( - namespace="test_extra_kwargs", settings_class=SimpleSettings, VALUE="initial" ) @@ -200,8 +176,8 @@ def test_get_settings_with_additional_kwargs(self, isolated_settings_manager): def test_get_settings_works_correctly(self, isolated_settings_manager): """Test that get_settings works correctly.""" params = SettingsParameters.create( - namespace="test_get_settings_unique", settings_class=SimpleSettings, + env_prefix="UNIQUE_GS_", VALUE="cached_value" ) @@ -210,7 +186,6 @@ def test_get_settings_works_correctly(self, isolated_settings_manager): assert isinstance(settings, SimpleSettings) assert settings.VALUE == "cached_value" - assert settings.SETTINGS_NAMESPACE == "test_get_settings_unique" class TestGetValidKwargNames: @@ -219,7 +194,7 @@ class TestGetValidKwargNames: @pytest.mark.unit def test_get_valid_kwarg_names_with_none_settings_class(self): """Test _get_valid_kwarg_names returns empty set when settings_class is None.""" - params = SettingsParameters.create(namespace="test") + params = SettingsParameters.create() result = params._get_valid_kwarg_names() @@ -228,7 +203,7 @@ def test_get_valid_kwarg_names_with_none_settings_class(self): @pytest.mark.unit def test_get_valid_kwarg_names_with_none_passed_and_none_stored(self): """Test _get_valid_kwarg_names with None passed explicitly and None stored.""" - params = SettingsParameters.create(namespace="test") + params = SettingsParameters.create() result = params._get_valid_kwarg_names(settings_class=None) @@ -237,7 +212,7 @@ def test_get_valid_kwarg_names_with_none_passed_and_none_stored(self): @pytest.mark.unit def test_get_valid_kwarg_names_with_class_provided(self): """Test _get_valid_kwarg_names with settings_class provided.""" - params = SettingsParameters.create(namespace="test") + params = SettingsParameters.create() result = params._get_valid_kwarg_names(settings_class=SimpleSettings) @@ -251,7 +226,6 @@ def test_get_valid_kwarg_names_with_class_provided(self): def test_get_valid_kwarg_names_uses_stored_class(self): """Test _get_valid_kwarg_names uses stored settings_class.""" params = SettingsParameters.create( - namespace="test", settings_class=SimpleSettings ) @@ -268,8 +242,8 @@ class TestApplyRuntimeOverrides: def test_apply_runtime_overrides_with_no_kwargs(self, isolated_settings_manager): """Test that apply_runtime_overrides returns original when no kwargs.""" params = SettingsParameters.create( - namespace="test_no_overrides", - settings_class=SimpleSettings + settings_class=SimpleSettings, + env_prefix="NO_OVERRIDE_" ) original_settings = params.get_settings() @@ -283,16 +257,16 @@ def test_apply_runtime_overrides_with_kwargs(self, isolated_settings_manager): """Test that apply_runtime_overrides creates copy with overrides.""" # Create cached settings params_base = SettingsParameters.create( - namespace="test_with_overrides", settings_class=SimpleSettings, + env_prefix="WITH_OVERRIDE_", VALUE="original" ) cached_settings = params_base.get_settings() # Create params with runtime overrides params_override = SettingsParameters.create( - namespace="test_with_overrides", settings_class=SimpleSettings, + env_prefix="WITH_OVERRIDE_", VALUE="overridden", COUNT=99 ) @@ -311,16 +285,16 @@ def test_apply_runtime_overrides_with_kwargs(self, isolated_settings_manager): def test_apply_runtime_overrides_with_empty_override_kwargs(self, isolated_settings_manager): """Test apply_runtime_overrides when kwargs exist but no valid overrides.""" params_base = SettingsParameters.create( - namespace="test_empty_overrides", settings_class=SimpleSettings, + env_prefix="EMPTY_OVERRIDE_", VALUE="original" ) cached_settings = params_base.get_settings() # Create params with kwargs but only invalid ones params_override = SettingsParameters( - namespace="test_empty_overrides", settings_class=SimpleSettings, + env_prefix="EMPTY_OVERRIDE_", kwargs={"invalid_field": "value"} # Not a valid field ) @@ -335,8 +309,8 @@ def test_apply_runtime_overrides_with_empty_override_kwargs(self, isolated_setti def test_apply_runtime_overrides_preserves_unmodified_fields(self, isolated_settings_manager): """Test that apply_runtime_overrides preserves unmodified fields.""" params_base = SettingsParameters.create( - namespace="test_preserves", settings_class=SimpleSettings, + env_prefix="PRESERVES_", VALUE="original_value", COUNT=10 ) @@ -344,8 +318,8 @@ def test_apply_runtime_overrides_preserves_unmodified_fields(self, isolated_sett # Override only one field params_override = SettingsParameters.create( - namespace="test_preserves", settings_class=SimpleSettings, + env_prefix="PRESERVES_", VALUE="new_value" ) @@ -363,8 +337,8 @@ class TestHashWithConfigFiles: @pytest.mark.unit def test_hash_with_none_config_files(self): """Test hash when config_files is None.""" - params1 = SettingsParameters.create(namespace="test", settings_class=TestSettings) - params2 = SettingsParameters.create(namespace="test", settings_class=TestSettings) + params1 = SettingsParameters.create(settings_class=TestSettings) + params2 = SettingsParameters.create(settings_class=TestSettings) assert hash(params1) == hash(params2) @@ -372,12 +346,10 @@ def test_hash_with_none_config_files(self): def test_hash_with_empty_config_files(self): """Test hash when config_files is empty.""" params1 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=[] ) params2 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=[] ) @@ -388,12 +360,10 @@ def test_hash_with_empty_config_files(self): def test_hash_different_config_file_order_normalized(self): """Test that config files in different order produce same hash (if sorted).""" params1 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=["a.yaml", "b.yaml"] ) params2 = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=["a.yaml", "b.yaml"] ) @@ -405,7 +375,6 @@ def test_hash_different_config_file_order_normalized(self): def test_hash_consistency_across_multiple_calls(self): """Test that hash is consistent across multiple calls.""" params = SettingsParameters.create( - namespace="test", settings_class=TestSettings, config_files=["config.yaml"], env_prefix="TEST_", @@ -426,7 +395,6 @@ class TestGetAttributeSettingsKwargs: def test_get_attribute_settings_kwargs_with_none_kwargs(self): """Test get_attribute_settings_kwargs returns empty dict when kwargs is None.""" params = SettingsParameters.create( - namespace="test", settings_class=SimpleSettings ) @@ -461,7 +429,7 @@ class TestGetPydanticKwargs: @pytest.mark.unit def test_get_pydantic_settings_kwargs_with_none_kwargs(self): """Test get_pydantic_settings_kwargs returns empty dict when kwargs is None.""" - params = SettingsParameters.create(namespace="test") + params = SettingsParameters.create() result = params.get_pydantic_settings_kwargs() @@ -470,7 +438,7 @@ def test_get_pydantic_settings_kwargs_with_none_kwargs(self): @pytest.mark.unit def test_get_pydantic_modelconfig_kwargs_with_none_kwargs(self): """Test get_pydantic_modelconfig_kwargs returns empty dict when kwargs is None.""" - params = SettingsParameters.create(namespace="test") + params = SettingsParameters.create() result = params.get_pydantic_modelconfig_kwargs() @@ -525,8 +493,8 @@ def test_full_workflow_with_runtime_overrides(self, isolated_settings_manager): """Test complete workflow with runtime overrides.""" # Create base parameters base_params = SettingsParameters.create( - namespace="integration_test_unique", settings_class=SimpleSettings, + env_prefix="INTEG_UNIQUE_", VALUE="base_value", COUNT=10 ) @@ -538,8 +506,8 @@ def test_full_workflow_with_runtime_overrides(self, isolated_settings_manager): # Create params with same structural but different runtime override_params = SettingsParameters.create( - namespace="integration_test_unique", settings_class=SimpleSettings, + env_prefix="INTEG_UNIQUE_", VALUE="override_value", COUNT=20 ) @@ -555,13 +523,13 @@ def test_caching_strategy_with_equality(self, isolated_settings_manager): """Test caching strategy based on equality.""" # These should be equal (same structural params, different runtime kwargs) params1 = SettingsParameters.create( - namespace="cache_equality_test", settings_class=SimpleSettings, + env_prefix="CACHE_EQ_", VALUE="value1" ) params2 = SettingsParameters.create( - namespace="cache_equality_test", settings_class=SimpleSettings, + env_prefix="CACHE_EQ_", VALUE="value2" ) @@ -573,8 +541,8 @@ def test_caching_strategy_with_equality(self, isolated_settings_manager): settings1 = isolated_settings_manager.get_or_create_settings(params1) settings2 = isolated_settings_manager.get_or_create_settings(params2) - # Should be same cached instance (structural params identical) - assert settings1 is settings2 + # Cache should have only one entry (same structural params) + assert len(isolated_settings_manager.settings_object_cache) == 1 class TestEdgeCases: @@ -594,16 +562,16 @@ def test_hash_with_all_none_structural_params(self): def test_apply_runtime_overrides_with_model_copy_preservation(self, isolated_settings_manager): """Test that apply_runtime_overrides preserves model integrity.""" params_base = SettingsParameters.create( - namespace="model_copy_test", settings_class=SimpleSettings, + env_prefix="MODEL_COPY_", VALUE="original", COUNT=5 ) cached = params_base.get_settings() params_override = SettingsParameters.create( - namespace="model_copy_test", settings_class=SimpleSettings, + env_prefix="MODEL_COPY_", COUNT=10 ) @@ -619,7 +587,6 @@ def test_apply_runtime_overrides_with_model_copy_preservation(self, isolated_set def test_to_dict_preserves_structure(self): """Test that to_dict preserves parameter structure.""" params = SettingsParameters.create( - namespace="dict_test", settings_class=SimpleSettings, config_files=["config1.yaml", "config2.yaml"], env_prefix="TEST_", @@ -631,7 +598,6 @@ def test_to_dict_preserves_structure(self): result = params.to_dict() # All fields should be present - assert result["namespace"] == "dict_test" assert isinstance(result["config_files"], list) assert len(result["config_files"]) == 2 assert result["settings_class"] is SimpleSettings diff --git a/tests/test_settings_utils.py b/tests/test_settings_utils.py deleted file mode 100644 index c8c9f6f..0000000 --- a/tests/test_settings_utils.py +++ /dev/null @@ -1,118 +0,0 @@ -from mountainash_settings import SettingsUtils, SettingsManager -from typing import Any, List -import pytest - -##=========================== -# Test formatting to and from hashable parameters - -def test_format_kwargs_dict_none(): - # Arrange - p_kwargs = None - - # Act - result = SettingsUtils.format_kwargs_dict(p_kwargs) - - # Assert - assert result is None - -def test_format_kwargs_dict_dict(): - # Arrange - p_kwargs = {"ORGANISATION_TLA": "XYZ", "PORTFOLIO_NAME": "ABC"} - - # Act - result = SettingsUtils.format_kwargs_dict(p_kwargs) - - # Assert - assert result == p_kwargs - -def test_format_kwargs_dict_tuple(): - # Arrange - - p_kwargs = {"ORGANISATION_TLA": "XYZ", "PORTFOLIO_NAME": "ABC"} - - t_kwargs = (("ORGANISATION_TLA", "XYZ"),("PORTFOLIO_NAME", "ABC")) - # p_kwargs = (("ORGANISATION_TLA", "XYZ"),("PORTFOLIO_NAME", "ABC")) - - # Act - result = SettingsUtils.format_kwargs_tuple(p_kwargs) - - # Assert - # assert result == tuple({"ORGANISATION_TLA": "XYZ","PORTFOLIO_NAME": "ABC" }) - #assert result == (("ORGANISATION_TLA", "XYZ"),("PORTFOLIO_NAME", "ABC")) - assert result == t_kwargs - -def test_format_kwargs_tuple_dict(): - # Arrange - # p_kwargs = {"ORGANISATION_TLA": "XYZ", "PORTFOLIO_NAME": "ABC"} - p_kwargs = (("ORGANISATION_TLA", "XYZ"),("PORTFOLIO_NAME", "ABC")) - - d_kwargs = {"ORGANISATION_TLA": "XYZ", "PORTFOLIO_NAME": "ABC"} - - # Act - result = SettingsUtils.format_kwargs_dict(p_kwargs) - - # Assert - assert result == d_kwargs - -def test_format_kwargs_tuple_tuple(): - # Arrange - - # p_kwargs = {"ORGANISATION_TLA": "XYZ", "PORTFOLIO_NAME": "ABC"} - p_kwargs = (("ORGANISATION_TLA", "XYZ"),("PORTFOLIO_NAME", "ABC")) - - d_kwargs = {"ORGANISATION_TLA": "XYZ", "PORTFOLIO_NAME": "ABC"} - t_kwargs = SettingsUtils.format_kwargs_tuple(d_kwargs) - - # Act - result = SettingsUtils.format_kwargs_tuple(p_kwargs) - - # Assert - # assert result == (("ORGANISATION_TLA", "XYZ"),("PORTFOLIO_NAME", "ABC")) - assert result == t_kwargs - - - -def test_format_kwargs_dict_invalid_type(): - # Arrange - p_kwargs = "invalid" - - # Act - with pytest.raises(ValueError): - result = SettingsUtils.format_kwargs_dict(p_kwargs = p_kwargs) - - # Assert - # assert result is None - - -##=========================== -# Test validation of kwargs helpers - - - -# Test case for when both new_config_files and original_config_files are None -def test_merge_config_files_both_none(): - - assert SettingsUtils.merge_config_files(config_files1=None, config_files2=None) is None - -# Test case for when new_config_files is not None and original_config_files is None -def test_merge_config_files_new_not_none(): - new_config_files: List[Any] = ["file1", "file2"] - - assert SettingsUtils.merge_config_files(config_files1=new_config_files) == tuple(new_config_files) - -# Test case for when new_config_files is None and original_config_files is not None -def test_merge_config_files_original_not_none(): - - original_config_files: List[Any] = ["file1", "file2"] - - assert SettingsUtils.merge_config_files(config_files2=original_config_files) == tuple(original_config_files) - -# Test case for when both new_config_files and original_config_files are not None -def test_merge_config_files_both_not_none(): - - new_config_files: List[Any] = ["file2", "file1"] - original_config_files: List[Any] = ["file4", "file3", "file1"] - expected_result: List[Any] = ["file1", "file2", "file3", "file4"] - - assert SettingsUtils.merge_config_files(config_files1=new_config_files, - config_files2=original_config_files) == tuple(expected_result)