Merge pull request #680 from cbcoutinho/docs/adr-024-dynaconf-config-management

docs: ADR-025 dynaconf configuration management
This commit is contained in:
Chris Coutinho
2026-04-07 15:49:46 +02:00
committed by GitHub
13 changed files with 1069 additions and 131 deletions
+4
View File
@@ -5,6 +5,10 @@ __pycache__/
.env.local
.env.*.local
# Dynaconf (ADR-024)
.secrets.toml
settings.local.toml
# Git
worktrees/
+13
View File
@@ -0,0 +1,13 @@
# Secrets configuration for Nextcloud MCP Server.
# Copy this file to .secrets.toml and fill in your values.
# .secrets.toml is gitignored — never commit real secrets.
#
# See docs/ADR-024-dynaconf-configuration-management.md for details.
[default]
token_encryption_key = ""
nextcloud_password = ""
nextcloud_oidc_client_secret = ""
qdrant_api_key = ""
openai_api_key = ""
custom_processor_api_key = ""
+4
View File
@@ -86,6 +86,7 @@ services:
- 127.0.0.1:9090:9090
volumes:
- mcp-data:/app/data
- ./settings.toml:/app/settings.toml:ro
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_USERNAME=admin
@@ -166,6 +167,7 @@ services:
# NO admin credentials - credentials come from client Authorization header
volumes:
- multi-user-basic-data:/app/data
- ./settings.toml:/app/settings.toml:ro
profiles:
- multi-user-basic
@@ -241,6 +243,7 @@ services:
volumes:
- keycloak-tokens:/app/data
- keycloak-oauth-storage:/app/.oauth
- ./settings.toml:/app/settings.toml:ro
profiles:
- keycloak
@@ -279,6 +282,7 @@ services:
volumes:
- login-flow-data:/app/data
- login-flow-oauth-storage:/app/.oauth
- ./settings.toml:/app/settings.toml:ro
profiles:
- login-flow
@@ -0,0 +1,556 @@
# ADR-024: Dynaconf Configuration Management
**Status:** Proposed
**Date:** 2026-04-04
**Deciders:** Development Team
**Related:** ADR-020 (Deployment Modes), ADR-021 (Configuration Consolidation), ADR-022 (Login Flow v2)
## Context
The nextcloud-mcp-server configuration system has grown to ~80+ environment variables across five deployment modes. All configuration is loaded via manual `os.getenv()` calls in `config.py` (~60 calls in `get_settings()` alone) and `providers/registry.py`. This creates several problems:
### Problems Identified
1. **No file-based configuration option**: Every deployment requires setting environment variables. For complex deployments with 20+ variables (e.g., Keycloak + semantic search + observability), this is unwieldy and error-prone. There is no way to ship a "configuration profile" as a file.
2. **Configuration sprawl across multiple locations**: Environment variables are read in at least three places:
- `config.py:get_settings()` — Main settings (~60 vars)
- `config.py:get_document_processor_config()` — Document processing (~20 vars)
- `providers/registry.py:ProviderRegistry.create_provider()` — Embedding providers (~15 vars)
3. **No configuration file for local development**: Developers must either maintain a `.env` file and `export $(grep -v '^#' .env | xargs)`, or rely solely on docker-compose environment blocks. A structured settings file with defaults per deployment mode would simplify onboarding.
4. **Manual type coercion is repetitive and error-prone**: The codebase is littered with patterns like:
```python
os.getenv("SOME_BOOL", "false").lower() == "true"
int(os.getenv("SOME_INT", "300"))
float(os.getenv("SOME_FLOAT", "1.0"))
```
Each is a potential `ValueError` if a user provides a non-numeric string for an integer field.
5. **No structured validation at load time**: While `config_validators.py` validates mode requirements after loading, there is no validation of individual field types, ranges, or mutual exclusivity at parse time. Invalid values (e.g., `METRICS_PORT=abc`) only fail when first used.
6. **Secrets mixed with configuration**: `TOKEN_ENCRYPTION_KEY`, `NEXTCLOUD_PASSWORD`, `OPENAI_API_KEY`, and other secrets are treated identically to non-sensitive configuration, with no separation mechanism.
### Current Configuration Surface
| Category | Approx. Vars | Example |
|----------|-------------|---------|
| Core Nextcloud | 6 | `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_VERIFY_SSL` |
| OAuth/OIDC | 12 | `OIDC_DISCOVERY_URL`, `NEXTCLOUD_OIDC_CLIENT_ID`, `JWKS_URI` |
| Mode Selection | 4 | `MCP_DEPLOYMENT_MODE`, `ENABLE_LOGIN_FLOW`, `ENABLE_TOKEN_EXCHANGE` |
| Token Storage | 3 | `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB` |
| Semantic Search | 6 | `ENABLE_SEMANTIC_SEARCH`, `VECTOR_SYNC_SCAN_INTERVAL` |
| Qdrant | 4 | `QDRANT_URL`, `QDRANT_LOCATION`, `QDRANT_API_KEY` |
| Embedding Providers | 15 | `OLLAMA_BASE_URL`, `OPENAI_API_KEY`, `BEDROCK_*` |
| Document Processing | 18 | `ENABLE_UNSTRUCTURED`, `TESSERACT_CMD`, `PYMUPDF_*` |
| Observability | 10 | `OTEL_EXPORTER_OTLP_ENDPOINT`, `LOG_FORMAT`, `METRICS_PORT` |
| Webhooks/Internal | 4 | `WEBHOOK_INTERNAL_URL`, `NEXTCLOUD_MCP_SERVICE_NAME` |
| **Total** | **~82** | |
## Decision
Adopt [dynaconf](https://www.dynaconf.com/) as the configuration management layer, enabling TOML file-based configuration alongside existing environment variable support.
### Why Dynaconf
| Criterion | Dynaconf | Pydantic Settings | python-dotenv |
|-----------|----------|-------------------|---------------|
| File-based config (TOML/YAML) | Yes (native) | No native TOML sections/env switching | `.env` only |
| Environment sections/profiles | Yes (`[default]`, `[production]`) | No | No |
| Env var override (12-factor) | Yes (built-in, highest priority) | Yes | Yes |
| Type coercion | Automatic (TOML parser) | Via type hints | No |
| Validators | Declarative + conditional | Via Pydantic | No |
| Secrets file separation | Yes (`.secrets.toml`) | No built-in | Separate `.env` |
| Local overrides | Yes (`settings.local.toml` auto-loaded) | No | No |
| Zero-prefix env vars | Yes (`envvar_prefix=False`) | Custom | N/A |
| Dependency | Pure Python, well-maintained | Pydantic (already in project for models) | Minimal |
### Architecture
#### 1. Dynaconf Instance Configuration
```python
# nextcloud_mcp_server/config.py
from pathlib import Path
from dynaconf import Dynaconf, Validator
_config_root = Path(__file__).parent.parent
settings = Dynaconf(
settings_files=["settings.toml", ".secrets.toml"],
root_path=str(_config_root),
environments=True,
env_switcher="MCP_DEPLOYMENT_MODE",
envvar_prefix=False,
load_dotenv=False,
ignore_unknown_envvars=True,
validators=[...], # See Section 4
)
```
Key choices:
- **`envvar_prefix=False`**: Existing env vars (`NEXTCLOUD_HOST`, `ENABLE_SEMANTIC_SEARCH`, etc.) work without any prefix. No renaming required.
- **`env_switcher="MCP_DEPLOYMENT_MODE"`**: Reuses the existing ADR-021 variable. Setting `MCP_DEPLOYMENT_MODE=single_user_basic` loads the `[single_user_basic]` TOML section on top of `[default]`. Note: dynaconf's `environments` feature is designed for lifecycle environments (dev/staging/prod), but custom environment names are a supported pattern — see `tests_functional/legacy/simple_ini_example/` in the dynaconf repo for a precedent using `environments=["ansible", "puppet"]`. **Legacy risk:** dynaconf docs flag `environments=True` as a legacy feature; if a future dynaconf major release removes it, we would need to migrate to the per-file approach (`settings.single_user_basic.toml`, etc.). This risk is acceptable given the alternative requires managing 5+ separate TOML files.
- **`ignore_unknown_envvars=True`**: Only env vars matching keys defined in `settings.toml` or defaults are loaded. System env vars (`HOME`, `PATH`, `LANG`) are ignored. **Important:** This means every env var the application reads must have a corresponding entry in `settings.toml`. See the `ignore_unknown_envvars` risk note under Consequences for mitigation.
- **`root_path=str(_config_root)`**: Anchors settings file lookup to the package's parent directory. Uses `str()` because dynaconf expects a string path. **Note on pip-installed packages:** When installed into a venv, `__file__` resolves to `site-packages/nextcloud_mcp_server/config.py` and `parent.parent` points inside site-packages — `settings.toml` will not be found there. This is intentional: pip-installed deployments are expected to use env vars (the primary configuration mechanism) or mount `settings.toml` into a location specified via `SETTINGS_FILE_FOR_DYNACONF`. The file-based config is a convenience for development and container deployments, not a requirement.
- **`load_dotenv=False`**: We don't auto-load `.env` files to avoid surprising behavior. Shell-level `.env` loading (e.g., `export $(grep -v '^#' .env | xargs)` as documented in CLAUDE.md) continues to work — env vars loaded into the shell before the process starts are picked up by dynaconf via its standard env var reading. Users who want automatic dotenv can use `direnv`.
#### 2. Settings File Structure
**`settings.toml`** — Shipped with the project, checked into git:
```toml
[default]
# === Nextcloud Connection ===
# nextcloud_host — Required, set via env var or .secrets.toml. No default.
nextcloud_verify_ssl = true
nextcloud_ca_bundle = "@none"
# === Deployment Mode ===
# Auto-detected if not set. Valid: single_user_basic, multi_user_basic,
# oauth_single_audience, login_flow, keycloak
# mcp_deployment_mode = ""
# === Authentication Toggles ===
enable_multi_user_basic_auth = false
enable_login_flow = false
enable_token_exchange = false
# === Token Storage ===
token_storage_db = "/tmp/tokens.db"
# === Semantic Search ===
enable_semantic_search = false
vector_sync_scan_interval = 300
vector_sync_processor_workers = 3
vector_sync_queue_max_size = 10000
vector_sync_user_poll_interval = 60
# === Qdrant ===
qdrant_location = ":memory:"
qdrant_collection = "nextcloud_content"
# === Embedding Providers ===
ollama_embedding_model = "nomic-embed-text"
ollama_verify_ssl = true
openai_embedding_model = "text-embedding-3-small"
simple_embedding_dimension = 384
# === Provider: Ollama ===
# ollama_base_url — Set via env var or .secrets.toml
ollama_generation_model = "@none"
# === Provider: Bedrock ===
aws_region = "@none"
bedrock_embedding_model = "@none"
bedrock_generation_model = "@none"
# aws_access_key_id — Set via env var or .secrets.toml
# aws_secret_access_key — Set via env var or .secrets.toml
# === Provider: Anthropic ===
# anthropic_api_key — Set via env var or .secrets.toml
# === Document Chunking ===
document_chunk_size = 2048
document_chunk_overlap = 200
# === Document Processing ===
enable_document_processing = false
document_processor = "unstructured"
enable_pymupdf = true
pymupdf_extract_images = true
enable_unstructured = false
unstructured_api_url = "http://unstructured:8000"
unstructured_timeout = 120
unstructured_strategy = "auto"
unstructured_languages = "eng,deu"
enable_tesseract = false
tesseract_lang = "eng"
enable_custom_processor = false
custom_processor_name = "custom"
custom_processor_types = "application/pdf"
custom_processor_timeout = 60
# === Observability ===
metrics_enabled = true
metrics_port = 9090
otel_service_name = "nextcloud-mcp-server"
otel_traces_sampler = "always_on"
otel_traces_sampler_arg = 1.0
otel_exporter_verify_ssl = false
log_format = "text"
log_level = "INFO"
log_include_trace_context = true
# === Webhooks ===
nextcloud_mcp_service_name = "mcp"
nextcloud_mcp_port = 8000
# ─────────────────────────────────────────────
# Deployment Mode Overrides
# ─────────────────────────────────────────────
[single_user_basic]
# Credentials provided via env vars or .secrets.toml
# nextcloud_username = "" (in .secrets.toml)
# nextcloud_password = "" (in .secrets.toml)
[multi_user_basic]
enable_multi_user_basic_auth = true
token_storage_db = "/app/data/tokens.db"
[login_flow]
enable_login_flow = true
token_storage_db = "/app/data/tokens.db"
[keycloak]
enable_token_exchange = true
token_storage_db = "/app/data/tokens.db"
token_exchange_cache_ttl = 300
[oauth_single_audience]
token_storage_db = "/app/data/tokens.db"
```
**`.secrets.toml.example`** — Template, checked into git (actual `.secrets.toml` is gitignored):
```toml
[default]
# token_encryption_key = ""
[single_user_basic]
# nextcloud_username = ""
# nextcloud_password = ""
# nextcloud_app_password = ""
[keycloak]
# nextcloud_oidc_client_id = ""
# nextcloud_oidc_client_secret = ""
# token_encryption_key = ""
[login_flow]
# token_encryption_key = ""
# Provider API keys (any deployment mode)
# ollama_base_url = ""
# anthropic_api_key = ""
# openai_api_key = ""
# aws_access_key_id = ""
# aws_secret_access_key = ""
# qdrant_api_key = ""
```
**`settings.local.toml`** — Personal overrides, gitignored, auto-loaded by dynaconf:
```toml
# Example developer overrides
[default]
log_level = "DEBUG"
ollama_base_url = "http://localhost:11434"
```
#### 3. Configuration Loading Priority
Dynaconf merges configuration in this order (last wins):
```
1. settings.toml [default] section ← base defaults
2. settings.toml [<mode>] section ← mode-specific overrides
3. .secrets.toml [default] section ← base secrets
4. .secrets.toml [<mode>] section ← mode-specific secrets
5. settings.local.toml (all sections) ← developer overrides
6. Environment variables ← highest priority (12-factor)
```
This means:
- **File-based config is optional** — env vars alone still work (they override everything)
- **Mode-specific defaults reduce boilerplate** — `[login_flow]` sets `enable_login_flow=true` and `token_storage_db=/app/data/tokens.db` so deployers don't need to
- **Secrets are separated** — `.secrets.toml` holds `TOKEN_ENCRYPTION_KEY`, passwords, API keys
- **Local dev overrides don't pollute** — `settings.local.toml` is gitignored
#### 4. Dynaconf Validators
Replace repetitive `__post_init__` checks with declarative validators:
```python
validators = [
# Required unconditionally — needed in all deployment modes
Validator("NEXTCLOUD_HOST", must_exist=True),
# Deployment mode validation — catch typos at startup
Validator("MCP_DEPLOYMENT_MODE", is_in=[
"single_user_basic", "multi_user_basic", "login_flow",
"keycloak", "oauth_single_audience",
], when=Validator("MCP_DEPLOYMENT_MODE", must_exist=True)),
# Type and range validation
Validator("METRICS_PORT", gte=1, lte=65535),
Validator("VECTOR_SYNC_SCAN_INTERVAL", gte=1),
Validator("VECTOR_SYNC_PROCESSOR_WORKERS", gte=1),
Validator("DOCUMENT_CHUNK_SIZE", gte=128),
Validator("DOCUMENT_CHUNK_OVERLAP", gte=0),
# OTEL_TRACES_SAMPLER_ARG only validated for ratio-based samplers
Validator(
"OTEL_TRACES_SAMPLER_ARG", gte=0.0, lte=1.0,
when=Validator("OTEL_TRACES_SAMPLER", condition=lambda v: "ratio" in str(v)),
),
# Enum validation
Validator("LOG_FORMAT", is_in=["text", "json"]),
Validator("LOG_LEVEL", is_in=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
Validator("OTEL_TRACES_SAMPLER", is_in=["always_on", "always_off", "parentbased_always_on", "parentbased_always_off", "traceidratio", "parentbased_traceidratio"]),
# Mutual exclusivity: QDRANT_URL and non-default QDRANT_LOCATION cannot both be set.
# QDRANT_LOCATION defaults to ":memory:", so check for non-default values.
# Note: Validator does not support `ne=` as a constructor kwarg — use `condition=` instead.
Validator("QDRANT_URL", must_exist=False, when=Validator("QDRANT_LOCATION", condition=lambda v: v != ":memory:")),
]
```
#### 5. Backward Compatibility: Deprecation Handling
Deprecated env var names (`VECTOR_SYNC_ENABLED`, `ENABLE_OFFLINE_ACCESS`) are mapped to their current equivalents. In **Phases 1-3**, this logic lives in the `get_settings()` adapter function, which already performs this mapping today via `_get_semantic_search_enabled()` and `_get_background_operations_enabled()`. Dynaconf simply replaces the `os.getenv()` calls that feed these helpers.
In **Phase 4 (optional, future)**, these could be migrated to dynaconf `post_hooks` — constructor callbacks that run after all sources are loaded. Each hook receives a clone of the settings and returns a dict of values to merge:
```python
# Phase 4 target (not implemented in Phases 1-3)
def handle_deprecations(settings):
"""Map deprecated variable names to current names (ADR-021 compatibility)."""
overrides = {}
if settings.exists("VECTOR_SYNC_ENABLED") and not settings.exists("ENABLE_SEMANTIC_SEARCH"):
overrides["ENABLE_SEMANTIC_SEARCH"] = settings.VECTOR_SYNC_ENABLED
logger.warning("VECTOR_SYNC_ENABLED is deprecated. Use ENABLE_SEMANTIC_SEARCH instead.")
if settings.exists("ENABLE_OFFLINE_ACCESS") and not settings.exists("ENABLE_BACKGROUND_OPERATIONS"):
overrides["ENABLE_BACKGROUND_OPERATIONS"] = settings.ENABLE_OFFLINE_ACCESS
logger.warning("ENABLE_OFFLINE_ACCESS is deprecated. Use ENABLE_BACKGROUND_OPERATIONS instead.")
return overrides if overrides else None
# Usage: Dynaconf(post_hooks=[handle_deprecations, resolve_dependencies], ...)
```
**Note on `post_hooks` API:** This is a supported `Dynaconf()` constructor parameter (defined on `DynaconfConfig`, with `post_hooks` declared in `base.py` since dynaconf 3.2.x). It is verified to work in dynaconf's functional test suite (`tests_functional/legacy/ignore_unknown_envvars/app.py`). However, since the existing Python helpers already implement this logic correctly, migrating to `post_hooks` is deferred to Phase 4 to avoid changing execution context and ordering relative to `config_validators.py`.
#### 6. Smart Dependency Resolution
The auto-enablement of `ENABLE_BACKGROUND_OPERATIONS` when semantic search is active in multi-user modes (existing behavior from ADR-021) is preserved. In **Phases 1-3**, the existing `_is_multi_user_mode()` and `_get_background_operations_enabled()` helpers continue to work, reading from dynaconf instead of `os.getenv()`.
In **Phase 4**, this could migrate to a post-hook:
```python
# Phase 4 target (not implemented in Phases 1-3)
def resolve_dependencies(settings):
"""Auto-enable background operations for semantic search in multi-user modes."""
is_multi_user = (
settings.get("ENABLE_MULTI_USER_BASIC_AUTH", False)
or settings.get("ENABLE_TOKEN_EXCHANGE", False)
or (not settings.get("NEXTCLOUD_USERNAME") and not settings.get("NEXTCLOUD_PASSWORD"))
)
if settings.get("ENABLE_SEMANTIC_SEARCH", False) and is_multi_user:
if not settings.get("ENABLE_BACKGROUND_OPERATIONS", False):
logger.info("Auto-enabled background operations for semantic search in multi-user mode.")
return {"ENABLE_BACKGROUND_OPERATIONS": True}
return None
```
#### 7. Adapter Layer (Migration Bridge)
During migration, `get_settings()` continues to return the `Settings` dataclass, populated from dynaconf:
```python
from dynaconf import Dynaconf
_dynaconf = Dynaconf(...) # As configured above
def get_settings() -> Settings:
"""Get application settings — backed by dynaconf."""
return Settings(
deployment_mode=_dynaconf.get("MCP_DEPLOYMENT_MODE"),
nextcloud_host=_dynaconf.get("NEXTCLOUD_HOST"),
nextcloud_username=_dynaconf.get("NEXTCLOUD_USERNAME"),
enable_token_exchange=_dynaconf.get("ENABLE_TOKEN_EXCHANGE", False),
# ... all fields populated from _dynaconf.get() instead of os.getenv()
)
```
This is a zero-risk change: every consumer of `get_settings()` sees the same `Settings` type. **Every field on the `Settings` dataclass must have a corresponding `_dynaconf.get()` call** — omitting a field (e.g., `enable_token_exchange`) would silently regress functionality. The implementation should use a `_field_map` dict to make this exhaustive mapping auditable. The dataclass can be removed in a later phase once all consumers migrate to `_dynaconf` directly.
#### 8. Mode Detection Preserved
`config_validators.py` is unchanged in this phase. `detect_auth_mode()` and `validate_configuration()` continue to operate on the `Settings` dataclass. The business logic for mode detection, conditional requirements, and forbidden variables is too complex for declarative validators and benefits from remaining as explicit Python code.
#### 9. Document Processor Config Integration
`get_document_processor_config()` currently reads ~20 env vars independently. It will be migrated to read from the same dynaconf instance, with document processor settings nested under the `[default]` section alongside all other settings.
#### 10. Provider Registry (Phase 6)
`providers/registry.py:ProviderRegistry.create_provider()` reads ~15 env vars directly via `os.getenv()`. **Until Phase 6**, these calls remain unchanged — they are not broken by Phases 1-3 because `ignore_unknown_envvars` only affects dynaconf's own env var loading, not direct `os.getenv()` calls in other modules. However, all provider env vars must still be declared in `settings.toml` (see Section 2) so that dynaconf-based code can access them. In Phase 6, `ProviderRegistry` will be updated to accept a settings object or read from the dynaconf instance, consolidating all configuration into a single source.
#### 11. Test Isolation
Tests must not be affected by `settings.toml` or `.secrets.toml` being present in the repository. Dynaconf provides several test isolation patterns — we recommend the **fixture factory** approach as the primary strategy:
**Primary: Fresh instance per test (best isolation)**
```python
# conftest.py
import pytest
from dynaconf import Dynaconf
@pytest.fixture
def test_settings(tmp_path):
"""Create a fresh Dynaconf instance with no file-based config."""
empty_toml = tmp_path / "settings.toml"
empty_toml.write_text("[default]\n")
return Dynaconf(
settings_files=[str(empty_toml)],
environments=True,
env_switcher="MCP_DEPLOYMENT_MODE",
envvar_prefix=False,
FORCE_ENV_FOR_DYNACONF="testing",
)
```
**Alternative: DynaconfDict for simple mocking**
```python
from dynaconf import DynaconfDict
def test_something():
"""Use DynaconfDict when only a few values are needed."""
mock_settings = DynaconfDict({
"NEXTCLOUD_HOST": "https://test.example.com",
"ENABLE_SEMANTIC_SEARCH": False,
})
result = some_function(mock_settings)
```
**Alternative: Module-level reload for integration tests**
Dynaconf instances do support `reload()` (defined in `dynaconf/base.py`), which clears all loaded values and re-executes all loaders. This can be used for integration tests that need the full loading pipeline:
```python
@pytest.fixture(autouse=True)
def isolated_settings(tmp_path, monkeypatch):
"""Reset the module-level dynaconf instance for integration tests.
Calls reload() + validate_all() in teardown to ensure the instance
is both reset and in a valid state for subsequent tests.
"""
monkeypatch.setenv("SETTINGS_FILE_FOR_DYNACONF", str(tmp_path / "empty.toml"))
(tmp_path / "empty.toml").write_text("[default]\n")
from nextcloud_mcp_server.config import _dynaconf
_dynaconf.reload()
yield
_dynaconf.reload()
# Re-validate after teardown to catch tests that leave invalid state
```
**Note on `_dynaconf` and `_reload_config`:** These are prefixed with `_` to signal internal use, but tests necessarily import them for isolation. This is an accepted trade-off. To prevent accidental production use, these names are intentionally excluded from `__all__` and carry docstrings noting they are test-accessible internals.
The fixture factory approach is preferred because it avoids global state mutation and is compatible with parallel test execution. Tests that need specific configuration values continue to use `monkeypatch.setenv()` as today, which overrides any file-based defaults (env vars have highest priority in dynaconf).
### Docker Compose Impact
**Zero breaking changes.** All existing `environment:` blocks in `docker-compose.yml` continue to work because `envvar_prefix=False` means env vars map directly to setting keys.
**Optional enhancement:** Users can mount settings files for cleaner configuration:
```yaml
mcp:
volumes:
# Note: settings.toml is checked into git, so it exists on the host.
# If the host file is missing, Docker creates a directory instead — this
# would cause a startup error, not silent misconfiguration.
- ./settings.toml:/app/settings.toml:ro
- ./.secrets.toml:/app/.secrets.toml:ro
environment:
# Only override what differs from settings.toml
- MCP_DEPLOYMENT_MODE=single_user_basic
- LOG_LEVEL=DEBUG
```
## Migration Strategy
### Phase 1: Add Dynaconf Foundation
- Add `dynaconf` dependency to `pyproject.toml`
- Create `settings.toml` with `[default]` values matching current defaults
- Create `.secrets.toml.example` template
- Add `.secrets.toml` and `settings.local.toml` to `.gitignore` (currently absent — existing `.gitignore` has `*.env` patterns but no dynaconf-specific entries)
- **Audit all `os.getenv()` calls** across the codebase (`config.py`, `providers/registry.py`, etc.) to ensure every env var has a corresponding `settings.toml` entry. This includes provider env vars (`AWS_REGION`, `BEDROCK_*`, `ANTHROPIC_API_KEY`, `OLLAMA_*`, `SIMPLE_EMBEDDING_DIMENSION`) which are critical because `ignore_unknown_envvars=True` silently drops unrecognized env vars.
- **Add CI lint check** (prerequisite for Phase 2): A script that extracts all `os.getenv()` keys and verifies each has a `settings.toml` entry. Phase 2 must not merge without this check passing in CI.
- Initialize `Dynaconf` instance in `config.py`
### Phase 2: Wire Adapter
- Replace `os.getenv()` calls in `get_settings()` with `_dynaconf.get()` calls
- Replace `os.getenv()` calls in `get_document_processor_config()` similarly
- `Settings` dataclass and all consumers unchanged
- All tests pass without modification
### Phase 3: Add Validators
- Add dynaconf `Validator` instances for type checking, range validation, and enum constraints
- Remove corresponding manual checks from `Settings.__post_init__`
### Phase 4: Deprecation and Dependency Hooks (Optional, Future)
- Move `_get_semantic_search_enabled()`, `_get_background_operations_enabled()`, and `_is_multi_user_mode()` logic into dynaconf post-hooks
- Remove standalone helper functions
- **Risk note:** These functions contain nuanced multi-variable logic (e.g., the `ENABLE_SEMANTIC_SEARCH` + `VECTOR_SYNC_ENABLED` OR pattern, the username/password presence check for mode detection). Running them as dynaconf post-hooks changes their execution context and ordering guarantees relative to `config_validators.py`. This phase should only proceed after Phases 1-3 are stable and well-tested.
### Phase 5: Direct Dynaconf Access (Optional, Future)
- Gradually replace `get_settings().field` with `settings.FIELD` in consumers
- Remove `Settings` dataclass once all consumers migrated
- This is a larger refactor touching ~30 files and can be deferred
### Phase 6: Provider Registry Consolidation (Optional, Future)
- Update `ProviderRegistry.create_provider()` to read from dynaconf
- Eliminates the last pocket of direct `os.getenv()` calls
## Consequences
### Positive
- **File-based configuration** enables shipping deployment profiles, reducing per-deployment env var count from 15-25 to 1-3 overrides
- **Automatic type coercion** eliminates ~30 manual `int()`, `float()`, `.lower() == "true"` patterns and their potential `ValueError` exceptions
- **Declarative validation** catches invalid configuration at startup with clear error messages
- **Secret separation** via `.secrets.toml` provides a standard pattern for credential management
- **Local overrides** via `settings.local.toml` simplify developer workflows without polluting git
- **12-factor compliant** — env vars always win, files are optional
- **Zero breaking changes** in Phases 1-3. Phase 4 is optional and carries moderate risk due to complex multi-variable logic.
### Negative
- **New dependency** — `dynaconf` is a runtime dependency (~50KB, pure Python, well-maintained)
- **Two configuration systems during migration** — Phases 1-3 run dynaconf alongside the existing `Settings` dataclass
- **Learning curve** — Contributors must understand dynaconf's merge semantics and environment sections
- **`envvar_prefix=False` risk** — Without a prefix, any env var matching a setting key is loaded. Mitigated by `ignore_unknown_envvars=True` which restricts to pre-defined keys only
- **`ignore_unknown_envvars=True` silent failure mode** — Env vars not declared in `settings.toml` are silently ignored. If a developer adds a new env var but forgets to add a corresponding entry in `settings.toml`, the value will silently be `None` at runtime instead of producing an error. This inverts the current failure mode (where `os.getenv()` returning `None` at least fails visibly at the point of use). **Mitigation (mandatory before Phase 2):** Phase 1 must complete a full audit of all `os.getenv()` calls across the codebase — including `config.py`, `providers/registry.py`, and any other modules — and add corresponding entries to `settings.toml`. A CI lint check (e.g., a script that greps for `os.getenv()` keys and verifies each has a `settings.toml` entry) must be added as part of Phase 1, not deferred. Until this CI check is in place, `ignore_unknown_envvars=True` should not be enabled.
- **`ValidationError` replaces `ValueError`** — Dynaconf validators raise `dynaconf.validator.ValidationError` instead of `ValueError`. Any external code catching `ValueError` from `Settings.__post_init__` (e.g., for `document_chunk_overlap < 0`) will need to be updated. This is a breaking change introduced in Phase 3 when validators replace manual checks.
- **`environments=True` is a legacy dynaconf feature** — The dynaconf docs recommend against it for new projects. If a future dynaconf major release removes it, we would need to migrate to per-file configuration (`settings.single_user_basic.toml`, etc.) or pinned TOML section names. This risk is accepted because the alternative requires managing 5+ separate files with duplicated defaults.
### Neutral
- **`config_validators.py` unchanged** — Mode detection and conditional validation remain as Python business logic. Dynaconf validators handle structural checks only.
- **Docker Compose files unchanged** — Existing `environment:` blocks work as-is. File mounting is optional.
- **`environments=True` with custom deployment mode names** — When `MCP_DEPLOYMENT_MODE` is unset, only the `[default]` TOML section is loaded. This is the correct behavior: the existing auto-detection logic in `config_validators.py` still determines the deployment mode post-load based on which env vars are present. The TOML sections provide *defaults per mode*, not mode detection. Note: `env_switcher="MCP_DEPLOYMENT_MODE"` takes precedence over dynaconf's default `ENV_FOR_DYNACONF` variable. Contributors should not set `ENV_FOR_DYNACONF` directly, as it would shadow the `env_switcher` configuration and cause confusing behavior.
## Alternatives Considered
### 1. Pydantic Settings
Pydantic v2's `BaseSettings` provides type validation and env var loading. As of pydantic-settings 2.x, it supports TOML files via `PyprojectTomlConfigSettingsSource` and custom settings sources. However, it lacks native TOML section-based environment switching and automatic secrets file separation, which are the primary motivations for this change. While Pydantic v2 is already used in the project for response models (`nextcloud_mcp_server/models/`), Pydantic Settings would require significant custom code to replicate dynaconf's `[default]`/`[mode]` section merging and `.secrets.toml` auto-loading.
### 2. python-decouple
Supports `.env` and `.ini` files with type casting. Lacks environment sections, validators, secrets separation, and TOML support. Too limited for our needs.
### 3. Custom TOML Loader
Build a minimal TOML loader using `tomllib` (stdlib in Python 3.11+). This avoids a dependency but requires implementing validation, env var override, secrets separation, and environment switching from scratch — essentially rebuilding dynaconf.
### 4. Status Quo (Env Vars Only)
Continue with `os.getenv()`. Acceptable for small projects, but with 80+ variables across 5 deployment modes, the lack of file-based configuration, validation, and defaults per mode is a growing maintenance burden.
## References
- [Dynaconf Documentation](https://www.dynaconf.com/)
- [12-Factor App: Config](https://12factor.net/config)
- ADR-020: Deployment Modes and Configuration Validation
- ADR-021: Configuration Consolidation and Simplification
- ADR-022: Login Flow v2
+185 -104
View File
@@ -4,8 +4,75 @@ import os
import socket
import ssl
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from dynaconf import Dynaconf, Validator
# Resolve root_path for dynaconf settings files.
# Editable installs: parent.parent is the project root (has settings.toml).
# Non-editable installs (Docker): settings.toml is mounted at WORKDIR (/app/).
_config_root = Path(__file__).parent.parent
if not (_config_root / "settings.toml").exists():
_config_root = Path.cwd()
# Sentinel for "key not in dynaconf at all" vs "explicitly set to None".
_UNSET = object()
# Dynaconf instance — loads settings.toml + .secrets.toml + env vars.
# Env vars always win (12-factor). See ADR-024 for architecture.
_dynaconf = Dynaconf(
settings_files=["settings.toml", ".secrets.toml"],
environments=True,
envvar_prefix=False,
env_switcher="MCP_DEPLOYMENT_MODE",
ignore_unknown_envvars=True,
root_path=str(_config_root),
load_dotenv=False,
validators=[
# Port ranges
Validator("METRICS_PORT", gte=1, lte=65535),
# Positive integers
Validator("VECTOR_SYNC_SCAN_INTERVAL", gte=1),
Validator("VECTOR_SYNC_PROCESSOR_WORKERS", gte=1),
Validator("VECTOR_SYNC_QUEUE_MAX_SIZE", gte=1),
Validator("VECTOR_SYNC_USER_POLL_INTERVAL", gte=1),
Validator("DOCUMENT_CHUNK_SIZE", gte=1),
# Non-negative
Validator("DOCUMENT_CHUNK_OVERLAP", gte=0),
# Enum constraints
Validator("LOG_FORMAT", is_in=["text", "json"]),
Validator(
"LOG_LEVEL",
is_in=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
),
Validator(
"OTEL_TRACES_SAMPLER",
is_in=[
"always_on",
"always_off",
"traceidratio",
"parentbased_always_on",
"parentbased_always_off",
"parentbased_traceidratio",
],
),
# Float ranges
Validator("OTEL_TRACES_SAMPLER_ARG", gte=0.0, lte=1.0),
],
)
def _reload_config():
"""Reload dynaconf settings from files and environment.
Call this in tests after modifying os.environ to refresh the cache.
Re-validates all validators since reload() only checks unchecked ones.
"""
_dynaconf.reload()
_dynaconf.validators.validate_all()
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
@@ -63,7 +130,7 @@ def setup_logging():
def get_document_processor_config() -> dict[str, Any]:
"""Get document processor configuration from environment.
"""Get document processor configuration from dynaconf.
Returns:
Dict with processor configs:
@@ -78,54 +145,54 @@ def get_document_processor_config() -> dict[str, Any]:
}
"""
config: dict[str, Any] = {
"enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true",
"default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"),
"enabled": _dynaconf.get("ENABLE_DOCUMENT_PROCESSING"),
"default_processor": _dynaconf.get("DOCUMENT_PROCESSOR"),
"processors": {},
}
# Unstructured configuration
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true":
if _dynaconf.get("ENABLE_UNSTRUCTURED"):
languages_str = _dynaconf.get("UNSTRUCTURED_LANGUAGES")
config["processors"]["unstructured"] = {
"api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
"timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")),
"strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"),
"api_url": _dynaconf.get("UNSTRUCTURED_API_URL"),
"timeout": _dynaconf.get("UNSTRUCTURED_TIMEOUT"),
"strategy": _dynaconf.get("UNSTRUCTURED_STRATEGY"),
"languages": [
lang.strip()
for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",")
if lang.strip()
lang.strip() for lang in languages_str.split(",") if lang.strip()
],
"progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")),
"progress_interval": _dynaconf.get("PROGRESS_INTERVAL"),
}
# Tesseract configuration
if os.getenv("ENABLE_TESSERACT", "false").lower() == "true":
if _dynaconf.get("ENABLE_TESSERACT"):
config["processors"]["tesseract"] = {
"tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect
"lang": os.getenv("TESSERACT_LANG", "eng"),
"tesseract_cmd": _dynaconf.get("TESSERACT_CMD"), # None = auto-detect
"lang": _dynaconf.get("TESSERACT_LANG"),
}
# PyMuPDF configuration (local PDF processing)
if os.getenv("ENABLE_PYMUPDF", "true").lower() == "true": # Enabled by default
if _dynaconf.get("ENABLE_PYMUPDF"): # Enabled by default
config["processors"]["pymupdf"] = {
"extract_images": os.getenv("PYMUPDF_EXTRACT_IMAGES", "true").lower()
== "true",
"image_dir": os.getenv("PYMUPDF_IMAGE_DIR"), # None = use temp directory
"extract_images": _dynaconf.get("PYMUPDF_EXTRACT_IMAGES"),
"image_dir": _dynaconf.get(
"PYMUPDF_IMAGE_DIR"
), # None = use temp directory
}
# Custom processor (via HTTP API)
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true":
custom_url = os.getenv("CUSTOM_PROCESSOR_URL")
if _dynaconf.get("ENABLE_CUSTOM_PROCESSOR"):
custom_url = _dynaconf.get("CUSTOM_PROCESSOR_URL")
if custom_url:
supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf")
supported_types_str = _dynaconf.get("CUSTOM_PROCESSOR_TYPES")
supported_types = {
t.strip() for t in supported_types_str.split(",") if t.strip()
}
config["processors"]["custom"] = {
"name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"),
"name": _dynaconf.get("CUSTOM_PROCESSOR_NAME"),
"api_url": custom_url,
"api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"),
"timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")),
"api_key": _dynaconf.get("CUSTOM_PROCESSOR_API_KEY"),
"timeout": _dynaconf.get("CUSTOM_PROCESSOR_TIMEOUT"),
"supported_types": supported_types,
}
@@ -277,11 +344,6 @@ class Settings:
f"Smaller chunks may lose context. Consider using at least 1024 characters."
)
if self.document_chunk_overlap < 0:
raise ValueError(
f"DOCUMENT_CHUNK_OVERLAP ({self.document_chunk_overlap}) cannot be negative."
)
def get_embedding_model_name(self) -> str:
"""
Get the active embedding model name based on provider priority.
@@ -371,8 +433,8 @@ def _get_semantic_search_enabled() -> bool:
"""
logger = logging.getLogger(__name__)
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
new_value = _dynaconf.get("ENABLE_SEMANTIC_SEARCH", False)
old_value = _dynaconf.get("VECTOR_SYNC_ENABLED", False)
if new_value and old_value:
logger.warning(
@@ -405,16 +467,16 @@ def _is_multi_user_mode() -> bool:
True if multi-user mode detected
"""
# Multi-user BasicAuth explicitly enabled
if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true":
if _dynaconf.get("ENABLE_MULTI_USER_BASIC_AUTH", False):
return True
# Token exchange implies OAuth multi-user
if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true":
if _dynaconf.get("ENABLE_TOKEN_EXCHANGE", False):
return True
# If both username and password are set, it's single-user BasicAuth
has_username = bool(os.getenv("NEXTCLOUD_USERNAME"))
has_password = bool(os.getenv("NEXTCLOUD_PASSWORD"))
has_username = bool(_dynaconf.get("NEXTCLOUD_USERNAME"))
has_password = bool(_dynaconf.get("NEXTCLOUD_PASSWORD"))
if has_username and has_password:
return False
@@ -436,8 +498,8 @@ def _get_background_operations_enabled() -> bool:
logger = logging.getLogger(__name__)
# Check new and old variable names
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
explicit = _dynaconf.get("ENABLE_BACKGROUND_OPERATIONS", False)
legacy = _dynaconf.get("ENABLE_OFFLINE_ACCESS", False)
if explicit and legacy:
logger.warning(
@@ -466,8 +528,29 @@ def _get_background_operations_enabled() -> bool:
return explicit or legacy or auto_enabled
def _dget(key):
"""Get a value from dynaconf if configured, otherwise return _UNSET.
Distinguishes "explicitly set to None" (via @none in TOML or env var)
from "not configured at all". When _UNSET is returned, callers should
let the Settings dataclass default apply.
"""
return _dynaconf[key] if key in _dynaconf else _UNSET
def get_settings() -> Settings:
"""Get application settings from environment variables.
"""Get application settings from dynaconf configuration.
Settings are loaded from (last wins):
1. settings.toml [default] section
2. settings.toml [<mode>] section (via MCP_DEPLOYMENT_MODE)
3. .secrets.toml (if present)
4. settings.local.toml (if present)
5. Environment variables (highest priority)
Values not found in any source are omitted, letting Settings dataclass
defaults apply. This ensures the server starts correctly even without
settings.toml (e.g., env-var-only deployments).
Returns:
Settings object with configuration values
@@ -476,86 +559,84 @@ def get_settings() -> Settings:
enable_semantic_search = _get_semantic_search_enabled()
enable_background_operations = _get_background_operations_enabled()
return Settings(
# Mapping from Settings field name to dynaconf key
_field_map = {
# Deployment mode (ADR-021)
deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"),
"deployment_mode": "MCP_DEPLOYMENT_MODE",
# OAuth/OIDC settings
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
oidc_client_secret=os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET"),
oidc_issuer=os.getenv("OIDC_ISSUER"),
"oidc_discovery_url": "OIDC_DISCOVERY_URL",
"oidc_client_id": "NEXTCLOUD_OIDC_CLIENT_ID",
"oidc_client_secret": "NEXTCLOUD_OIDC_CLIENT_SECRET",
"oidc_issuer": "OIDC_ISSUER",
# Nextcloud settings
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"),
"nextcloud_host": "NEXTCLOUD_HOST",
"nextcloud_username": "NEXTCLOUD_USERNAME",
"nextcloud_password": "NEXTCLOUD_PASSWORD",
"nextcloud_app_password": "NEXTCLOUD_APP_PASSWORD",
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl=(
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
),
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
"nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL",
"nextcloud_ca_bundle": "NEXTCLOUD_CA_BUNDLE",
# ADR-005: Token Audience Validation
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
"nextcloud_mcp_server_url": "NEXTCLOUD_MCP_SERVER_URL",
"nextcloud_resource_uri": "NEXTCLOUD_RESOURCE_URI",
# Token verification endpoints
jwks_uri=os.getenv("JWKS_URI"),
introspection_uri=os.getenv("INTROSPECTION_URI"),
userinfo_uri=os.getenv("USERINFO_URI"),
# Progressive Consent settings (always enabled)
enable_offline_access=enable_background_operations, # Smart dependency resolution
"jwks_uri": "JWKS_URI",
"introspection_uri": "INTROSPECTION_URI",
"userinfo_uri": "USERINFO_URI",
# Multi-user BasicAuth pass-through mode
enable_multi_user_basic_auth=(
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
),
"enable_multi_user_basic_auth": "ENABLE_MULTI_USER_BASIC_AUTH",
# Login Flow v2 settings (ADR-022)
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
# Token and webhook storage settings (encryption key optional for webhook-only usage)
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
"enable_login_flow": "ENABLE_LOGIN_FLOW",
# Token and webhook storage settings
"token_encryption_key": "TOKEN_ENCRYPTION_KEY",
"token_storage_db": "TOKEN_STORAGE_DB",
# Vector sync settings (ADR-007)
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
vector_sync_processor_workers=int(
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
),
vector_sync_queue_max_size=int(
os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000")
),
vector_sync_user_poll_interval=int(
os.getenv("VECTOR_SYNC_USER_POLL_INTERVAL", "60")
),
"vector_sync_scan_interval": "VECTOR_SYNC_SCAN_INTERVAL",
"vector_sync_processor_workers": "VECTOR_SYNC_PROCESSOR_WORKERS",
"vector_sync_queue_max_size": "VECTOR_SYNC_QUEUE_MAX_SIZE",
"vector_sync_user_poll_interval": "VECTOR_SYNC_USER_POLL_INTERVAL",
# Qdrant settings
qdrant_url=os.getenv("QDRANT_URL"),
qdrant_location=os.getenv("QDRANT_LOCATION"),
qdrant_api_key=os.getenv("QDRANT_API_KEY"),
qdrant_collection=os.getenv("QDRANT_COLLECTION", "nextcloud_content"),
"qdrant_url": "QDRANT_URL",
"qdrant_location": "QDRANT_LOCATION",
"qdrant_api_key": "QDRANT_API_KEY",
"qdrant_collection": "QDRANT_COLLECTION",
# Ollama settings
ollama_base_url=os.getenv("OLLAMA_BASE_URL"),
ollama_embedding_model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"),
ollama_verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true",
"ollama_base_url": "OLLAMA_BASE_URL",
"ollama_embedding_model": "OLLAMA_EMBEDDING_MODEL",
"ollama_verify_ssl": "OLLAMA_VERIFY_SSL",
# OpenAI settings
openai_api_key=os.getenv("OPENAI_API_KEY"),
openai_base_url=os.getenv("OPENAI_BASE_URL"),
openai_embedding_model=os.getenv(
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
),
"openai_api_key": "OPENAI_API_KEY",
"openai_base_url": "OPENAI_BASE_URL",
"openai_embedding_model": "OPENAI_EMBEDDING_MODEL",
# Document chunking settings
document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "2048")),
document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "200")),
"document_chunk_size": "DOCUMENT_CHUNK_SIZE",
"document_chunk_overlap": "DOCUMENT_CHUNK_OVERLAP",
# Observability settings
metrics_enabled=os.getenv("METRICS_ENABLED", "true").lower() == "true",
metrics_port=int(os.getenv("METRICS_PORT", "9090")),
otel_exporter_otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
otel_exporter_verify_ssl=os.getenv("OTEL_EXPORTER_VERIFY_SSL", "false").lower()
== "true",
otel_service_name=os.getenv("OTEL_SERVICE_NAME", "nextcloud-mcp-server"),
otel_traces_sampler=os.getenv("OTEL_TRACES_SAMPLER", "always_on"),
otel_traces_sampler_arg=float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0")),
log_format=os.getenv("LOG_FORMAT", "text"),
log_level=os.getenv("LOG_LEVEL", "INFO"),
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
== "true",
)
"metrics_enabled": "METRICS_ENABLED",
"metrics_port": "METRICS_PORT",
"otel_exporter_otlp_endpoint": "OTEL_EXPORTER_OTLP_ENDPOINT",
"otel_exporter_verify_ssl": "OTEL_EXPORTER_VERIFY_SSL",
"otel_service_name": "OTEL_SERVICE_NAME",
"otel_traces_sampler": "OTEL_TRACES_SAMPLER",
"otel_traces_sampler_arg": "OTEL_TRACES_SAMPLER_ARG",
"log_format": "LOG_FORMAT",
"log_level": "LOG_LEVEL",
"log_include_trace_context": "LOG_INCLUDE_TRACE_CONTEXT",
}
# Only pass values that dynaconf actually has; omit unset keys so
# the Settings dataclass defaults apply.
kwargs = {
field: val
for field, key in _field_map.items()
if (val := _dget(key)) is not _UNSET
}
# Smart dependency overrides (always set, regardless of dynaconf)
kwargs["vector_sync_enabled"] = enable_semantic_search
kwargs["enable_offline_access"] = enable_background_operations
return Settings(**kwargs)
def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
+1
View File
@@ -43,6 +43,7 @@ dependencies = [
"pymupdf4llm>=0.2.2",
"pymupdf-layout>=1.26.6",
"openai>=2.8.1",
"dynaconf>=3.2.13,<4.0",
]
classifiers = [
"Development Status :: 4 - Beta",
+131
View File
@@ -0,0 +1,131 @@
# Nextcloud MCP Server Configuration
# This file defines all configuration keys with their default values.
# Environment variables always override values from this file.
#
# Loading priority (last wins):
# 1. settings.toml [default] section
# 2. settings.toml [<mode>] section (via MCP_DEPLOYMENT_MODE)
# 3. .secrets.toml [default] section
# 4. .secrets.toml [<mode>] section
# 5. settings.local.toml (all sections, gitignored)
# 6. Environment variables (highest priority)
#
# See docs/ADR-024-dynaconf-configuration-management.md for details.
[default]
# --- Deployment mode (ADR-021) ---
# Valid: single_user_basic, multi_user_basic, login_flow, keycloak, oauth_single_audience
# If unset, mode is auto-detected from other settings.
mcp_deployment_mode = "@none"
# --- Nextcloud core ---
nextcloud_host = "@none"
nextcloud_username = "@none"
nextcloud_password = "@none"
nextcloud_app_password = "@none"
nextcloud_verify_ssl = true
nextcloud_ca_bundle = "@none"
nextcloud_mcp_server_url = "@none"
nextcloud_resource_uri = "@none"
# --- OAuth/OIDC ---
oidc_discovery_url = "@none"
nextcloud_oidc_client_id = "@none"
nextcloud_oidc_client_secret = "@none"
oidc_issuer = "@none"
jwks_uri = "@none"
introspection_uri = "@none"
userinfo_uri = "@none"
# --- Mode flags ---
enable_multi_user_basic_auth = false
enable_login_flow = false
enable_semantic_search = false
enable_background_operations = false
# Deprecated aliases (declared so env var overrides work)
vector_sync_enabled = false
enable_offline_access = false
enable_token_exchange = false
# --- Token storage ---
token_encryption_key = "@none"
token_storage_db = "/tmp/tokens.db"
# --- Vector sync ---
vector_sync_scan_interval = 300
vector_sync_processor_workers = 3
vector_sync_queue_max_size = 10000
vector_sync_user_poll_interval = 60
# --- Qdrant ---
# No default for qdrant_location — conditional default (:memory:) is in Settings.__post_init__
qdrant_url = "@none"
qdrant_location = "@none"
qdrant_api_key = "@none"
qdrant_collection = "nextcloud_content"
# --- Ollama ---
ollama_base_url = "@none"
ollama_embedding_model = "nomic-embed-text"
ollama_verify_ssl = true
# --- OpenAI ---
openai_api_key = "@none"
openai_base_url = "@none"
openai_embedding_model = "text-embedding-3-small"
# --- Document chunking ---
document_chunk_size = 2048
document_chunk_overlap = 200
# --- Observability ---
metrics_enabled = true
metrics_port = 9090
otel_exporter_otlp_endpoint = "@none"
otel_exporter_verify_ssl = false
otel_service_name = "nextcloud-mcp-server"
otel_traces_sampler = "always_on"
otel_traces_sampler_arg = 1.0
log_format = "text"
log_level = "INFO"
log_include_trace_context = true
# --- Document processing ---
enable_document_processing = false
document_processor = "unstructured"
enable_unstructured = false
unstructured_api_url = "http://unstructured:8000"
unstructured_timeout = 120
unstructured_strategy = "auto"
unstructured_languages = "eng,deu"
progress_interval = 10
enable_tesseract = false
tesseract_cmd = "@none"
tesseract_lang = "eng"
enable_pymupdf = true
pymupdf_extract_images = true
pymupdf_image_dir = "@none"
enable_custom_processor = false
custom_processor_url = "@none"
custom_processor_types = "application/pdf"
custom_processor_name = "custom"
custom_processor_api_key = "@none"
custom_processor_timeout = 60
# --- Deployment mode sections ---
# Keys here override [default] when MCP_DEPLOYMENT_MODE matches.
[single_user_basic]
[multi_user_basic]
enable_multi_user_basic_auth = true
[login_flow]
enable_login_flow = true
[keycloak]
[oauth_single_audience]
+21
View File
@@ -0,0 +1,21 @@
"""Unit test configuration — shared fixtures for all unit tests."""
import pytest
@pytest.fixture(autouse=True)
def _reload_dynaconf_after_test():
"""Ensure dynaconf cache is clean between tests.
Dynaconf caches env var values at load time. Tests that modify os.environ
must call _reload_config() to refresh the cache. This fixture reloads
after each test to prevent leaked state.
Uses _dynaconf.reload() directly (without validate_all) since the
real env may have values that don't pass validators. Tests that need
validation should call _reload_config() explicitly.
"""
yield
from nextcloud_mcp_server.config import _dynaconf
_dynaconf.reload()
+102 -10
View File
@@ -6,7 +6,7 @@ from unittest.mock import patch
import pytest
from nextcloud_mcp_server.config import Settings, get_settings
from nextcloud_mcp_server.config import Settings, _reload_config, get_settings
class TestQdrantConfigValidation:
@@ -74,6 +74,7 @@ class TestGetSettings:
@patch.dict(os.environ, {}, clear=True)
def test_get_settings_defaults_to_memory(self):
"""Test get_settings() defaults to :memory: when no env vars set."""
_reload_config()
settings = get_settings()
assert settings.qdrant_location == ":memory:"
assert settings.qdrant_url is None
@@ -88,6 +89,7 @@ class TestGetSettings:
)
def test_get_settings_network_mode(self):
"""Test get_settings() with network mode env vars."""
_reload_config()
settings = get_settings()
assert settings.qdrant_url == "http://qdrant:6333"
assert settings.qdrant_api_key == "test-key"
@@ -100,6 +102,7 @@ class TestGetSettings:
)
def test_get_settings_persistent_mode(self):
"""Test get_settings() with persistent local mode env vars."""
_reload_config()
settings = get_settings()
assert settings.qdrant_location == "/app/data/qdrant"
assert settings.qdrant_url is None
@@ -111,6 +114,7 @@ class TestGetSettings:
)
def test_get_settings_explicit_memory(self):
"""Test get_settings() with explicit :memory: env var."""
_reload_config()
settings = get_settings()
assert settings.qdrant_location == ":memory:"
assert settings.qdrant_url is None
@@ -125,6 +129,7 @@ class TestGetSettings:
)
def test_get_settings_mutual_exclusion_error(self):
"""Test get_settings() raises error when both URL and location set."""
_reload_config()
with pytest.raises(
ValueError,
match="Cannot set both QDRANT_URL and QDRANT_LOCATION",
@@ -144,6 +149,7 @@ class TestGetSettings:
)
def test_get_settings_vector_sync_config(self):
"""Test get_settings() with vector sync configuration."""
_reload_config()
settings = get_settings()
assert settings.qdrant_collection == "test_collection"
assert settings.vector_sync_enabled is True
@@ -192,16 +198,17 @@ class TestChunkConfigValidation:
document_chunk_overlap=300,
)
@patch.dict(
os.environ,
{"DOCUMENT_CHUNK_OVERLAP": "-10"},
clear=True,
)
def test_negative_overlap_raises_error(self):
"""Test that negative overlap raises ValueError."""
with pytest.raises(
ValueError,
match="DOCUMENT_CHUNK_OVERLAP .* cannot be negative",
):
Settings(
document_chunk_size=512,
document_chunk_overlap=-10,
)
"""Test that negative overlap raises ValidationError via dynaconf."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="DOCUMENT_CHUNK_OVERLAP"):
_reload_config()
def test_small_chunk_size_warning(self, caplog):
"""Test that chunk size < 512 triggers warning."""
@@ -237,6 +244,7 @@ class TestChunkConfigValidation:
)
def test_get_settings_chunk_config(self):
"""Test get_settings() with chunk configuration."""
_reload_config()
settings = get_settings()
assert settings.document_chunk_size == 1024
assert settings.document_chunk_overlap == 102
@@ -251,6 +259,7 @@ class TestChunkConfigValidation:
)
def test_get_settings_invalid_chunk_config_raises_error(self):
"""Test get_settings() raises error for invalid chunk config."""
_reload_config()
with pytest.raises(
ValueError,
match="DOCUMENT_CHUNK_OVERLAP .* must be less than DOCUMENT_CHUNK_SIZE",
@@ -294,6 +303,7 @@ class TestEmbeddingModelName:
)
def test_get_settings_openai_model(self):
"""Test get_settings() loads OpenAI embedding model."""
_reload_config()
settings = get_settings()
assert settings.openai_api_key == "test-openai-key"
assert settings.openai_embedding_model == "openai/text-embedding-3-small"
@@ -342,3 +352,85 @@ class TestCollectionNameWithProviders:
openai_embedding_model="text-embedding-3-large",
)
assert settings.get_collection_name() == "custom-collection"
class TestDynaconfValidators:
"""Test dynaconf declarative validators (ADR-024 Phase 3)."""
@patch.dict(os.environ, {"METRICS_PORT": "0"}, clear=True)
def test_metrics_port_too_low(self):
"""Test METRICS_PORT below minimum raises ValidationError."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="METRICS_PORT"):
_reload_config()
@patch.dict(os.environ, {"METRICS_PORT": "99999"}, clear=True)
def test_metrics_port_too_high(self):
"""Test METRICS_PORT above maximum raises ValidationError."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="METRICS_PORT"):
_reload_config()
@patch.dict(os.environ, {"LOG_FORMAT": "xml"}, clear=True)
def test_invalid_log_format(self):
"""Test invalid LOG_FORMAT raises ValidationError."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="LOG_FORMAT"):
_reload_config()
@patch.dict(os.environ, {"LOG_LEVEL": "VERBOSE"}, clear=True)
def test_invalid_log_level(self):
"""Test invalid LOG_LEVEL raises ValidationError."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="LOG_LEVEL"):
_reload_config()
@patch.dict(os.environ, {"OTEL_TRACES_SAMPLER": "random"}, clear=True)
def test_invalid_otel_sampler(self):
"""Test invalid OTEL_TRACES_SAMPLER raises ValidationError."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="OTEL_TRACES_SAMPLER"):
_reload_config()
@patch.dict(os.environ, {"OTEL_TRACES_SAMPLER_ARG": "2.0"}, clear=True)
def test_sampler_arg_too_high(self):
"""Test OTEL_TRACES_SAMPLER_ARG above 1.0 raises ValidationError."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="OTEL_TRACES_SAMPLER_ARG"):
_reload_config()
@patch.dict(os.environ, {"VECTOR_SYNC_SCAN_INTERVAL": "0"}, clear=True)
def test_vector_sync_interval_zero(self):
"""Test zero VECTOR_SYNC_SCAN_INTERVAL raises ValidationError."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="VECTOR_SYNC_SCAN_INTERVAL"):
_reload_config()
@patch.dict(os.environ, {"DOCUMENT_CHUNK_SIZE": "0"}, clear=True)
def test_chunk_size_zero(self):
"""Test zero DOCUMENT_CHUNK_SIZE raises ValidationError."""
from dynaconf import ValidationError
with pytest.raises(ValidationError, match="DOCUMENT_CHUNK_SIZE"):
_reload_config()
@patch.dict(os.environ, {"METRICS_PORT": "8080"}, clear=True)
def test_valid_metrics_port(self):
"""Test valid METRICS_PORT passes validation."""
_reload_config()
settings = get_settings()
assert settings.metrics_port == 8080
@patch.dict(os.environ, {"LOG_FORMAT": "json"}, clear=True)
def test_valid_log_format_json(self):
"""Test valid LOG_FORMAT=json passes validation."""
_reload_config()
settings = get_settings()
assert settings.log_format == "json"
+20 -1
View File
@@ -10,7 +10,7 @@ Tests cover:
import os
from unittest.mock import patch
from nextcloud_mcp_server.config import Settings
from nextcloud_mcp_server.config import Settings, _reload_config
from nextcloud_mcp_server.config_validators import (
AuthMode,
detect_auth_mode,
@@ -274,6 +274,7 @@ class TestMultiUserBasicValidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode, errors = validate_configuration(settings)
@@ -372,6 +373,7 @@ class TestOAuthSingleAudienceValidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode, errors = validate_configuration(settings)
@@ -459,6 +461,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
assert settings.vector_sync_enabled is True
@@ -474,6 +477,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
assert settings.vector_sync_enabled is True
@@ -490,6 +494,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
assert settings.enable_offline_access is True
@@ -506,6 +511,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
assert settings.enable_offline_access is True
@@ -525,6 +531,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
# Semantic search enabled
@@ -549,6 +556,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
# Semantic search enabled
@@ -572,6 +580,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
# Semantic search NOT enabled
@@ -593,6 +602,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
# Should use new name value (true)
@@ -612,6 +622,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
# Should use new name value (true)
@@ -637,6 +648,7 @@ class TestConfigurationConsolidation:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode, errors = validate_configuration(settings)
@@ -671,6 +683,7 @@ class TestExplicitModeSelection:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode = detect_auth_mode(settings)
@@ -688,6 +701,7 @@ class TestExplicitModeSelection:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode = detect_auth_mode(settings)
@@ -705,6 +719,7 @@ class TestExplicitModeSelection:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode = detect_auth_mode(settings)
@@ -722,6 +737,7 @@ class TestExplicitModeSelection:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
# Should raise ValueError with clear message
@@ -747,6 +763,7 @@ class TestExplicitModeSelection:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode = detect_auth_mode(settings)
@@ -765,6 +782,7 @@ class TestExplicitModeSelection:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode = detect_auth_mode(settings)
@@ -782,6 +800,7 @@ class TestExplicitModeSelection:
):
from nextcloud_mcp_server.config import get_settings
_reload_config()
settings = get_settings()
mode = detect_auth_mode(settings)
+10 -14
View File
@@ -4,6 +4,8 @@ import os
import pytest
from nextcloud_mcp_server.config import _reload_config, get_document_processor_config
pytestmark = pytest.mark.unit
@@ -12,18 +14,16 @@ class TestDocumentProcessorConfig:
def test_config_disabled_by_default(self):
"""Test that document processing is disabled by default."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ.pop("ENABLE_DOCUMENT_PROCESSING", None)
_reload_config()
config = get_document_processor_config()
assert config["enabled"] is False
def test_config_enabled(self):
"""Test enabling document processing."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true"
try:
_reload_config()
config = get_document_processor_config()
assert config["enabled"] is True
finally:
@@ -31,8 +31,6 @@ class TestDocumentProcessorConfig:
def test_unstructured_processor_config(self):
"""Test Unstructured processor configuration."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_UNSTRUCTURED"] = "true"
os.environ["UNSTRUCTURED_API_URL"] = "http://test:8000"
os.environ["UNSTRUCTURED_STRATEGY"] = "hi_res"
@@ -40,6 +38,7 @@ class TestDocumentProcessorConfig:
os.environ["UNSTRUCTURED_TIMEOUT"] = "60"
try:
_reload_config()
config = get_document_processor_config()
assert "unstructured" in config["processors"]
unst_config = config["processors"]["unstructured"]
@@ -56,13 +55,12 @@ class TestDocumentProcessorConfig:
def test_tesseract_processor_config(self):
"""Test Tesseract processor configuration."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_TESSERACT"] = "true"
os.environ["TESSERACT_LANG"] = "eng+deu"
os.environ["TESSERACT_CMD"] = "/usr/local/bin/tesseract"
try:
_reload_config()
config = get_document_processor_config()
assert "tesseract" in config["processors"]
tess_config = config["processors"]["tesseract"]
@@ -75,8 +73,6 @@ class TestDocumentProcessorConfig:
def test_custom_processor_config(self):
"""Test custom processor configuration."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_CUSTOM_PROCESSOR"] = "true"
os.environ["CUSTOM_PROCESSOR_NAME"] = "my_ocr"
os.environ["CUSTOM_PROCESSOR_URL"] = "http://localhost:9000/process"
@@ -85,6 +81,7 @@ class TestDocumentProcessorConfig:
os.environ["CUSTOM_PROCESSOR_TYPES"] = "application/pdf,image/jpeg"
try:
_reload_config()
config = get_document_processor_config()
assert "custom" in config["processors"]
custom_config = config["processors"]["custom"]
@@ -104,13 +101,12 @@ class TestDocumentProcessorConfig:
def test_multiple_processors(self):
"""Test configuration with multiple processors enabled."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true"
os.environ["ENABLE_UNSTRUCTURED"] = "true"
os.environ["ENABLE_TESSERACT"] = "true"
try:
_reload_config()
config = get_document_processor_config()
assert config["enabled"] is True
assert "unstructured" in config["processors"]
@@ -122,14 +118,14 @@ class TestDocumentProcessorConfig:
def test_default_processor_selection(self):
"""Test default processor configuration."""
from nextcloud_mcp_server.config import get_document_processor_config
os.environ.pop("DOCUMENT_PROCESSOR", None)
_reload_config()
config = get_document_processor_config()
assert config["default_processor"] == "unstructured"
os.environ["DOCUMENT_PROCESSOR"] = "tesseract"
try:
_reload_config()
config = get_document_processor_config()
assert config["default_processor"] == "tesseract"
finally:
+11 -2
View File
@@ -9,7 +9,12 @@ import certifi
import httpx
import pytest
from nextcloud_mcp_server.config import Settings, get_nextcloud_ssl_verify, get_settings
from nextcloud_mcp_server.config import (
Settings,
_reload_config,
get_nextcloud_ssl_verify,
get_settings,
)
from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport
@@ -50,7 +55,7 @@ class TestGetNextcloudSSLVerify:
"NEXTCLOUD_VERIFY_SSL": "true",
}
with patch.dict(os.environ, env, clear=False):
# Clear any cached settings
_reload_config()
result = get_nextcloud_ssl_verify()
assert result is True
@@ -110,12 +115,14 @@ class TestGetSettingsSSLEnvVars:
def test_verify_ssl_env_true(self):
env = {"NEXTCLOUD_VERIFY_SSL": "true"}
with patch.dict(os.environ, env, clear=False):
_reload_config()
settings = get_settings()
assert settings.nextcloud_verify_ssl is True
def test_verify_ssl_env_false(self):
env = {"NEXTCLOUD_VERIFY_SSL": "false"}
with patch.dict(os.environ, env, clear=False):
_reload_config()
settings = get_settings()
assert settings.nextcloud_verify_ssl is False
@@ -123,6 +130,7 @@ class TestGetSettingsSSLEnvVars:
with patch.dict(os.environ, {}, clear=False):
# Remove NEXTCLOUD_VERIFY_SSL if it exists
os.environ.pop("NEXTCLOUD_VERIFY_SSL", None)
_reload_config()
settings = get_settings()
assert settings.nextcloud_verify_ssl is True
@@ -133,6 +141,7 @@ class TestGetSettingsSSLEnvVars:
)
env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)}
with patch.dict(os.environ, env, clear=False):
_reload_config()
settings = get_settings()
assert settings.nextcloud_ca_bundle == str(ca_file)
Generated
+11
View File
@@ -760,6 +760,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "dynaconf"
version = "3.2.13"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/0e/05927cf459e73f8bf9a9277cbea6f2d5b7db8a5cc9dc1e20e7a5fbac1b90/dynaconf-3.2.13.tar.gz", hash = "sha256:d79e0189d97b3f226b8ebb1717e2ce05d1a05cdf6ea05de66d24625fdb5a0cbd", size = 283507, upload-time = "2026-03-17T19:38:47.632Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/43/11d6e5d2c00bf000b5329717c74563bf76a9193f4a41cb0c4ef277dde4fa/dynaconf-3.2.13-py2.py3-none-any.whl", hash = "sha256:4305527aef4834bdba3e39479b23c005186e83fb85f65bcaa4bcea58fa26759b", size = 238041, upload-time = "2026-03-17T19:38:45.337Z" },
]
[[package]]
name = "executing"
version = "2.2.1"
@@ -2096,6 +2105,7 @@ dependencies = [
{ name = "boto3" },
{ name = "caldav" },
{ name = "click" },
{ name = "dynaconf" },
{ name = "fastembed" },
{ name = "httpx" },
{ name = "icalendar" },
@@ -2149,6 +2159,7 @@ requires-dist = [
{ name = "boto3", specifier = ">=1.35.0" },
{ name = "caldav", specifier = ">=3.0.1,<4.0" },
{ name = "click", specifier = ">=8.1.8" },
{ name = "dynaconf", specifier = ">=3.2.13,<4.0" },
{ name = "fastembed", specifier = ">=0.7.3" },
{ name = "httpx", specifier = ">=0.28.1,<0.29.0" },
{ name = "icalendar", specifier = ">=7.0.2,<7.1.0" },