feat: implement dynaconf configuration management (ADR-024 phases 1-3)
Replace ~80 manual os.getenv() calls in config.py with dynaconf-backed configuration, enabling TOML file-based config alongside existing env var support. Zero breaking changes — Settings dataclass interface and all consumers unchanged. Phase 1: Create settings.toml with all config keys and defaults, .secrets.toml.example template, update .gitignore, initialize Dynaconf instance with envvar_prefix=False and environment section switching. Phase 2: Wire adapter — replace os.getenv() with _dynaconf.get() in get_settings(), get_document_processor_config(), and deprecation/ dependency resolution helpers. Automatic type coercion eliminates ~30 manual int()/float()/.lower()=="true" patterns. Phase 3: Add 12 declarative validators for port ranges, positive integers, enum constraints, and float ranges. Remove redundant negative overlap check from Settings.__post_init__. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f34c74afbc
commit
c8e4cbe825
@@ -5,6 +5,10 @@ __pycache__/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# Dynaconf (ADR-024)
|
||||||
|
.secrets.toml
|
||||||
|
settings.local.toml
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
worktrees/
|
worktrees/
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
+143
-98
@@ -4,8 +4,65 @@ import os
|
|||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from dynaconf import Dynaconf, Validator
|
||||||
|
|
||||||
|
# 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(Path(__file__).parent.parent),
|
||||||
|
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 = {
|
LOGGING_CONFIG = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
@@ -63,7 +120,7 @@ def setup_logging():
|
|||||||
|
|
||||||
|
|
||||||
def get_document_processor_config() -> dict[str, Any]:
|
def get_document_processor_config() -> dict[str, Any]:
|
||||||
"""Get document processor configuration from environment.
|
"""Get document processor configuration from dynaconf.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with processor configs:
|
Dict with processor configs:
|
||||||
@@ -78,54 +135,54 @@ def get_document_processor_config() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
config: dict[str, Any] = {
|
config: dict[str, Any] = {
|
||||||
"enabled": os.getenv("ENABLE_DOCUMENT_PROCESSING", "false").lower() == "true",
|
"enabled": _dynaconf.get("ENABLE_DOCUMENT_PROCESSING"),
|
||||||
"default_processor": os.getenv("DOCUMENT_PROCESSOR", "unstructured"),
|
"default_processor": _dynaconf.get("DOCUMENT_PROCESSOR"),
|
||||||
"processors": {},
|
"processors": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
# Unstructured configuration
|
# Unstructured configuration
|
||||||
if os.getenv("ENABLE_UNSTRUCTURED", "false").lower() == "true":
|
if _dynaconf.get("ENABLE_UNSTRUCTURED"):
|
||||||
|
languages_str = _dynaconf.get("UNSTRUCTURED_LANGUAGES")
|
||||||
config["processors"]["unstructured"] = {
|
config["processors"]["unstructured"] = {
|
||||||
"api_url": os.getenv("UNSTRUCTURED_API_URL", "http://unstructured:8000"),
|
"api_url": _dynaconf.get("UNSTRUCTURED_API_URL"),
|
||||||
"timeout": int(os.getenv("UNSTRUCTURED_TIMEOUT", "120")),
|
"timeout": _dynaconf.get("UNSTRUCTURED_TIMEOUT"),
|
||||||
"strategy": os.getenv("UNSTRUCTURED_STRATEGY", "auto"),
|
"strategy": _dynaconf.get("UNSTRUCTURED_STRATEGY"),
|
||||||
"languages": [
|
"languages": [
|
||||||
lang.strip()
|
lang.strip() for lang in languages_str.split(",") if lang.strip()
|
||||||
for lang in os.getenv("UNSTRUCTURED_LANGUAGES", "eng,deu").split(",")
|
|
||||||
if lang.strip()
|
|
||||||
],
|
],
|
||||||
"progress_interval": int(os.getenv("PROGRESS_INTERVAL", "10")),
|
"progress_interval": _dynaconf.get("PROGRESS_INTERVAL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tesseract configuration
|
# Tesseract configuration
|
||||||
if os.getenv("ENABLE_TESSERACT", "false").lower() == "true":
|
if _dynaconf.get("ENABLE_TESSERACT"):
|
||||||
config["processors"]["tesseract"] = {
|
config["processors"]["tesseract"] = {
|
||||||
"tesseract_cmd": os.getenv("TESSERACT_CMD"), # None = auto-detect
|
"tesseract_cmd": _dynaconf.get("TESSERACT_CMD"), # None = auto-detect
|
||||||
"lang": os.getenv("TESSERACT_LANG", "eng"),
|
"lang": _dynaconf.get("TESSERACT_LANG"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# PyMuPDF configuration (local PDF processing)
|
# 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"] = {
|
config["processors"]["pymupdf"] = {
|
||||||
"extract_images": os.getenv("PYMUPDF_EXTRACT_IMAGES", "true").lower()
|
"extract_images": _dynaconf.get("PYMUPDF_EXTRACT_IMAGES"),
|
||||||
== "true",
|
"image_dir": _dynaconf.get(
|
||||||
"image_dir": os.getenv("PYMUPDF_IMAGE_DIR"), # None = use temp directory
|
"PYMUPDF_IMAGE_DIR"
|
||||||
|
), # None = use temp directory
|
||||||
}
|
}
|
||||||
|
|
||||||
# Custom processor (via HTTP API)
|
# Custom processor (via HTTP API)
|
||||||
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true":
|
if _dynaconf.get("ENABLE_CUSTOM_PROCESSOR"):
|
||||||
custom_url = os.getenv("CUSTOM_PROCESSOR_URL")
|
custom_url = _dynaconf.get("CUSTOM_PROCESSOR_URL")
|
||||||
if custom_url:
|
if custom_url:
|
||||||
supported_types_str = os.getenv("CUSTOM_PROCESSOR_TYPES", "application/pdf")
|
supported_types_str = _dynaconf.get("CUSTOM_PROCESSOR_TYPES")
|
||||||
supported_types = {
|
supported_types = {
|
||||||
t.strip() for t in supported_types_str.split(",") if t.strip()
|
t.strip() for t in supported_types_str.split(",") if t.strip()
|
||||||
}
|
}
|
||||||
|
|
||||||
config["processors"]["custom"] = {
|
config["processors"]["custom"] = {
|
||||||
"name": os.getenv("CUSTOM_PROCESSOR_NAME", "custom"),
|
"name": _dynaconf.get("CUSTOM_PROCESSOR_NAME"),
|
||||||
"api_url": custom_url,
|
"api_url": custom_url,
|
||||||
"api_key": os.getenv("CUSTOM_PROCESSOR_API_KEY"),
|
"api_key": _dynaconf.get("CUSTOM_PROCESSOR_API_KEY"),
|
||||||
"timeout": int(os.getenv("CUSTOM_PROCESSOR_TIMEOUT", "60")),
|
"timeout": _dynaconf.get("CUSTOM_PROCESSOR_TIMEOUT"),
|
||||||
"supported_types": supported_types,
|
"supported_types": supported_types,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,11 +334,6 @@ class Settings:
|
|||||||
f"Smaller chunks may lose context. Consider using at least 1024 characters."
|
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:
|
def get_embedding_model_name(self) -> str:
|
||||||
"""
|
"""
|
||||||
Get the active embedding model name based on provider priority.
|
Get the active embedding model name based on provider priority.
|
||||||
@@ -371,8 +423,8 @@ def _get_semantic_search_enabled() -> bool:
|
|||||||
"""
|
"""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
|
new_value = _dynaconf.get("ENABLE_SEMANTIC_SEARCH", False)
|
||||||
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
|
old_value = _dynaconf.get("VECTOR_SYNC_ENABLED", False)
|
||||||
|
|
||||||
if new_value and old_value:
|
if new_value and old_value:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -405,16 +457,16 @@ def _is_multi_user_mode() -> bool:
|
|||||||
True if multi-user mode detected
|
True if multi-user mode detected
|
||||||
"""
|
"""
|
||||||
# Multi-user BasicAuth explicitly enabled
|
# 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
|
return True
|
||||||
|
|
||||||
# Token exchange implies OAuth multi-user
|
# Token exchange implies OAuth multi-user
|
||||||
if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true":
|
if _dynaconf.get("ENABLE_TOKEN_EXCHANGE", False):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# If both username and password are set, it's single-user BasicAuth
|
# If both username and password are set, it's single-user BasicAuth
|
||||||
has_username = bool(os.getenv("NEXTCLOUD_USERNAME"))
|
has_username = bool(_dynaconf.get("NEXTCLOUD_USERNAME"))
|
||||||
has_password = bool(os.getenv("NEXTCLOUD_PASSWORD"))
|
has_password = bool(_dynaconf.get("NEXTCLOUD_PASSWORD"))
|
||||||
if has_username and has_password:
|
if has_username and has_password:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -436,8 +488,8 @@ def _get_background_operations_enabled() -> bool:
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Check new and old variable names
|
# Check new and old variable names
|
||||||
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
|
explicit = _dynaconf.get("ENABLE_BACKGROUND_OPERATIONS", False)
|
||||||
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
|
legacy = _dynaconf.get("ENABLE_OFFLINE_ACCESS", False)
|
||||||
|
|
||||||
if explicit and legacy:
|
if explicit and legacy:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -467,7 +519,14 @@ def _get_background_operations_enabled() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
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)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Settings object with configuration values
|
Settings object with configuration values
|
||||||
@@ -478,83 +537,69 @@ def get_settings() -> Settings:
|
|||||||
|
|
||||||
return Settings(
|
return Settings(
|
||||||
# Deployment mode (ADR-021)
|
# Deployment mode (ADR-021)
|
||||||
deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"),
|
deployment_mode=_dynaconf.get("MCP_DEPLOYMENT_MODE"),
|
||||||
# OAuth/OIDC settings
|
# OAuth/OIDC settings
|
||||||
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
|
oidc_discovery_url=_dynaconf.get("OIDC_DISCOVERY_URL"),
|
||||||
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
|
oidc_client_id=_dynaconf.get("NEXTCLOUD_OIDC_CLIENT_ID"),
|
||||||
oidc_client_secret=os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET"),
|
oidc_client_secret=_dynaconf.get("NEXTCLOUD_OIDC_CLIENT_SECRET"),
|
||||||
oidc_issuer=os.getenv("OIDC_ISSUER"),
|
oidc_issuer=_dynaconf.get("OIDC_ISSUER"),
|
||||||
# Nextcloud settings
|
# Nextcloud settings
|
||||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
nextcloud_host=_dynaconf.get("NEXTCLOUD_HOST"),
|
||||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
nextcloud_username=_dynaconf.get("NEXTCLOUD_USERNAME"),
|
||||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
nextcloud_password=_dynaconf.get("NEXTCLOUD_PASSWORD"),
|
||||||
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"),
|
nextcloud_app_password=_dynaconf.get("NEXTCLOUD_APP_PASSWORD"),
|
||||||
# Nextcloud SSL/TLS settings
|
# Nextcloud SSL/TLS settings
|
||||||
nextcloud_verify_ssl=(
|
nextcloud_verify_ssl=_dynaconf.get("NEXTCLOUD_VERIFY_SSL"),
|
||||||
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
nextcloud_ca_bundle=_dynaconf.get("NEXTCLOUD_CA_BUNDLE"),
|
||||||
),
|
|
||||||
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
|
|
||||||
# ADR-005: Token Audience Validation
|
# ADR-005: Token Audience Validation
|
||||||
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
nextcloud_mcp_server_url=_dynaconf.get("NEXTCLOUD_MCP_SERVER_URL"),
|
||||||
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
nextcloud_resource_uri=_dynaconf.get("NEXTCLOUD_RESOURCE_URI"),
|
||||||
# Token verification endpoints
|
# Token verification endpoints
|
||||||
jwks_uri=os.getenv("JWKS_URI"),
|
jwks_uri=_dynaconf.get("JWKS_URI"),
|
||||||
introspection_uri=os.getenv("INTROSPECTION_URI"),
|
introspection_uri=_dynaconf.get("INTROSPECTION_URI"),
|
||||||
userinfo_uri=os.getenv("USERINFO_URI"),
|
userinfo_uri=_dynaconf.get("USERINFO_URI"),
|
||||||
# Progressive Consent settings (always enabled)
|
# Progressive Consent settings (always enabled)
|
||||||
enable_offline_access=enable_background_operations, # Smart dependency resolution
|
enable_offline_access=enable_background_operations, # Smart dependency resolution
|
||||||
# Multi-user BasicAuth pass-through mode
|
# Multi-user BasicAuth pass-through mode
|
||||||
enable_multi_user_basic_auth=(
|
enable_multi_user_basic_auth=_dynaconf.get("ENABLE_MULTI_USER_BASIC_AUTH"),
|
||||||
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
|
||||||
),
|
|
||||||
# Login Flow v2 settings (ADR-022)
|
# Login Flow v2 settings (ADR-022)
|
||||||
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
|
enable_login_flow=_dynaconf.get("ENABLE_LOGIN_FLOW"),
|
||||||
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
||||||
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
token_encryption_key=_dynaconf.get("TOKEN_ENCRYPTION_KEY"),
|
||||||
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
token_storage_db=_dynaconf.get("TOKEN_STORAGE_DB"),
|
||||||
# Vector sync settings (ADR-007)
|
# Vector sync settings (ADR-007)
|
||||||
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
|
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
|
||||||
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
|
vector_sync_scan_interval=_dynaconf.get("VECTOR_SYNC_SCAN_INTERVAL"),
|
||||||
vector_sync_processor_workers=int(
|
vector_sync_processor_workers=_dynaconf.get("VECTOR_SYNC_PROCESSOR_WORKERS"),
|
||||||
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
|
vector_sync_queue_max_size=_dynaconf.get("VECTOR_SYNC_QUEUE_MAX_SIZE"),
|
||||||
),
|
vector_sync_user_poll_interval=_dynaconf.get("VECTOR_SYNC_USER_POLL_INTERVAL"),
|
||||||
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")
|
|
||||||
),
|
|
||||||
# Qdrant settings
|
# Qdrant settings
|
||||||
qdrant_url=os.getenv("QDRANT_URL"),
|
qdrant_url=_dynaconf.get("QDRANT_URL"),
|
||||||
qdrant_location=os.getenv("QDRANT_LOCATION"),
|
qdrant_location=_dynaconf.get("QDRANT_LOCATION"),
|
||||||
qdrant_api_key=os.getenv("QDRANT_API_KEY"),
|
qdrant_api_key=_dynaconf.get("QDRANT_API_KEY"),
|
||||||
qdrant_collection=os.getenv("QDRANT_COLLECTION", "nextcloud_content"),
|
qdrant_collection=_dynaconf.get("QDRANT_COLLECTION"),
|
||||||
# Ollama settings
|
# Ollama settings
|
||||||
ollama_base_url=os.getenv("OLLAMA_BASE_URL"),
|
ollama_base_url=_dynaconf.get("OLLAMA_BASE_URL"),
|
||||||
ollama_embedding_model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"),
|
ollama_embedding_model=_dynaconf.get("OLLAMA_EMBEDDING_MODEL"),
|
||||||
ollama_verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true",
|
ollama_verify_ssl=_dynaconf.get("OLLAMA_VERIFY_SSL"),
|
||||||
# OpenAI settings
|
# OpenAI settings
|
||||||
openai_api_key=os.getenv("OPENAI_API_KEY"),
|
openai_api_key=_dynaconf.get("OPENAI_API_KEY"),
|
||||||
openai_base_url=os.getenv("OPENAI_BASE_URL"),
|
openai_base_url=_dynaconf.get("OPENAI_BASE_URL"),
|
||||||
openai_embedding_model=os.getenv(
|
openai_embedding_model=_dynaconf.get("OPENAI_EMBEDDING_MODEL"),
|
||||||
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
|
|
||||||
),
|
|
||||||
# Document chunking settings
|
# Document chunking settings
|
||||||
document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "2048")),
|
document_chunk_size=_dynaconf.get("DOCUMENT_CHUNK_SIZE"),
|
||||||
document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "200")),
|
document_chunk_overlap=_dynaconf.get("DOCUMENT_CHUNK_OVERLAP"),
|
||||||
# Observability settings
|
# Observability settings
|
||||||
metrics_enabled=os.getenv("METRICS_ENABLED", "true").lower() == "true",
|
metrics_enabled=_dynaconf.get("METRICS_ENABLED"),
|
||||||
metrics_port=int(os.getenv("METRICS_PORT", "9090")),
|
metrics_port=_dynaconf.get("METRICS_PORT"),
|
||||||
otel_exporter_otlp_endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
otel_exporter_otlp_endpoint=_dynaconf.get("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
||||||
otel_exporter_verify_ssl=os.getenv("OTEL_EXPORTER_VERIFY_SSL", "false").lower()
|
otel_exporter_verify_ssl=_dynaconf.get("OTEL_EXPORTER_VERIFY_SSL"),
|
||||||
== "true",
|
otel_service_name=_dynaconf.get("OTEL_SERVICE_NAME"),
|
||||||
otel_service_name=os.getenv("OTEL_SERVICE_NAME", "nextcloud-mcp-server"),
|
otel_traces_sampler=_dynaconf.get("OTEL_TRACES_SAMPLER"),
|
||||||
otel_traces_sampler=os.getenv("OTEL_TRACES_SAMPLER", "always_on"),
|
otel_traces_sampler_arg=_dynaconf.get("OTEL_TRACES_SAMPLER_ARG"),
|
||||||
otel_traces_sampler_arg=float(os.getenv("OTEL_TRACES_SAMPLER_ARG", "1.0")),
|
log_format=_dynaconf.get("LOG_FORMAT"),
|
||||||
log_format=os.getenv("LOG_FORMAT", "text"),
|
log_level=_dynaconf.get("LOG_LEVEL"),
|
||||||
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
log_include_trace_context=_dynaconf.get("LOG_INCLUDE_TRACE_CONTEXT"),
|
||||||
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
|
||||||
== "true",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+131
@@ -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]
|
||||||
@@ -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
@@ -6,7 +6,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import Settings, get_settings
|
from nextcloud_mcp_server.config import Settings, _reload_config, get_settings
|
||||||
|
|
||||||
|
|
||||||
class TestQdrantConfigValidation:
|
class TestQdrantConfigValidation:
|
||||||
@@ -74,6 +74,7 @@ class TestGetSettings:
|
|||||||
@patch.dict(os.environ, {}, clear=True)
|
@patch.dict(os.environ, {}, clear=True)
|
||||||
def test_get_settings_defaults_to_memory(self):
|
def test_get_settings_defaults_to_memory(self):
|
||||||
"""Test get_settings() defaults to :memory: when no env vars set."""
|
"""Test get_settings() defaults to :memory: when no env vars set."""
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.qdrant_location == ":memory:"
|
assert settings.qdrant_location == ":memory:"
|
||||||
assert settings.qdrant_url is None
|
assert settings.qdrant_url is None
|
||||||
@@ -88,6 +89,7 @@ class TestGetSettings:
|
|||||||
)
|
)
|
||||||
def test_get_settings_network_mode(self):
|
def test_get_settings_network_mode(self):
|
||||||
"""Test get_settings() with network mode env vars."""
|
"""Test get_settings() with network mode env vars."""
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.qdrant_url == "http://qdrant:6333"
|
assert settings.qdrant_url == "http://qdrant:6333"
|
||||||
assert settings.qdrant_api_key == "test-key"
|
assert settings.qdrant_api_key == "test-key"
|
||||||
@@ -100,6 +102,7 @@ class TestGetSettings:
|
|||||||
)
|
)
|
||||||
def test_get_settings_persistent_mode(self):
|
def test_get_settings_persistent_mode(self):
|
||||||
"""Test get_settings() with persistent local mode env vars."""
|
"""Test get_settings() with persistent local mode env vars."""
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.qdrant_location == "/app/data/qdrant"
|
assert settings.qdrant_location == "/app/data/qdrant"
|
||||||
assert settings.qdrant_url is None
|
assert settings.qdrant_url is None
|
||||||
@@ -111,6 +114,7 @@ class TestGetSettings:
|
|||||||
)
|
)
|
||||||
def test_get_settings_explicit_memory(self):
|
def test_get_settings_explicit_memory(self):
|
||||||
"""Test get_settings() with explicit :memory: env var."""
|
"""Test get_settings() with explicit :memory: env var."""
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.qdrant_location == ":memory:"
|
assert settings.qdrant_location == ":memory:"
|
||||||
assert settings.qdrant_url is None
|
assert settings.qdrant_url is None
|
||||||
@@ -125,6 +129,7 @@ class TestGetSettings:
|
|||||||
)
|
)
|
||||||
def test_get_settings_mutual_exclusion_error(self):
|
def test_get_settings_mutual_exclusion_error(self):
|
||||||
"""Test get_settings() raises error when both URL and location set."""
|
"""Test get_settings() raises error when both URL and location set."""
|
||||||
|
_reload_config()
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="Cannot set both QDRANT_URL and QDRANT_LOCATION",
|
match="Cannot set both QDRANT_URL and QDRANT_LOCATION",
|
||||||
@@ -144,6 +149,7 @@ class TestGetSettings:
|
|||||||
)
|
)
|
||||||
def test_get_settings_vector_sync_config(self):
|
def test_get_settings_vector_sync_config(self):
|
||||||
"""Test get_settings() with vector sync configuration."""
|
"""Test get_settings() with vector sync configuration."""
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.qdrant_collection == "test_collection"
|
assert settings.qdrant_collection == "test_collection"
|
||||||
assert settings.vector_sync_enabled is True
|
assert settings.vector_sync_enabled is True
|
||||||
@@ -192,16 +198,17 @@ class TestChunkConfigValidation:
|
|||||||
document_chunk_overlap=300,
|
document_chunk_overlap=300,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_negative_overlap_raises_error(self):
|
@patch.dict(
|
||||||
"""Test that negative overlap raises ValueError."""
|
os.environ,
|
||||||
with pytest.raises(
|
{"DOCUMENT_CHUNK_OVERLAP": "-10"},
|
||||||
ValueError,
|
clear=True,
|
||||||
match="DOCUMENT_CHUNK_OVERLAP .* cannot be negative",
|
|
||||||
):
|
|
||||||
Settings(
|
|
||||||
document_chunk_size=512,
|
|
||||||
document_chunk_overlap=-10,
|
|
||||||
)
|
)
|
||||||
|
def test_negative_overlap_raises_error(self):
|
||||||
|
"""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):
|
def test_small_chunk_size_warning(self, caplog):
|
||||||
"""Test that chunk size < 512 triggers warning."""
|
"""Test that chunk size < 512 triggers warning."""
|
||||||
@@ -237,6 +244,7 @@ class TestChunkConfigValidation:
|
|||||||
)
|
)
|
||||||
def test_get_settings_chunk_config(self):
|
def test_get_settings_chunk_config(self):
|
||||||
"""Test get_settings() with chunk configuration."""
|
"""Test get_settings() with chunk configuration."""
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.document_chunk_size == 1024
|
assert settings.document_chunk_size == 1024
|
||||||
assert settings.document_chunk_overlap == 102
|
assert settings.document_chunk_overlap == 102
|
||||||
@@ -251,6 +259,7 @@ class TestChunkConfigValidation:
|
|||||||
)
|
)
|
||||||
def test_get_settings_invalid_chunk_config_raises_error(self):
|
def test_get_settings_invalid_chunk_config_raises_error(self):
|
||||||
"""Test get_settings() raises error for invalid chunk config."""
|
"""Test get_settings() raises error for invalid chunk config."""
|
||||||
|
_reload_config()
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
ValueError,
|
ValueError,
|
||||||
match="DOCUMENT_CHUNK_OVERLAP .* must be less than DOCUMENT_CHUNK_SIZE",
|
match="DOCUMENT_CHUNK_OVERLAP .* must be less than DOCUMENT_CHUNK_SIZE",
|
||||||
@@ -294,6 +303,7 @@ class TestEmbeddingModelName:
|
|||||||
)
|
)
|
||||||
def test_get_settings_openai_model(self):
|
def test_get_settings_openai_model(self):
|
||||||
"""Test get_settings() loads OpenAI embedding model."""
|
"""Test get_settings() loads OpenAI embedding model."""
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.openai_api_key == "test-openai-key"
|
assert settings.openai_api_key == "test-openai-key"
|
||||||
assert settings.openai_embedding_model == "openai/text-embedding-3-small"
|
assert settings.openai_embedding_model == "openai/text-embedding-3-small"
|
||||||
@@ -342,3 +352,85 @@ class TestCollectionNameWithProviders:
|
|||||||
openai_embedding_model="text-embedding-3-large",
|
openai_embedding_model="text-embedding-3-large",
|
||||||
)
|
)
|
||||||
assert settings.get_collection_name() == "custom-collection"
|
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"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Tests cover:
|
|||||||
import os
|
import os
|
||||||
from unittest.mock import patch
|
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 (
|
from nextcloud_mcp_server.config_validators import (
|
||||||
AuthMode,
|
AuthMode,
|
||||||
detect_auth_mode,
|
detect_auth_mode,
|
||||||
@@ -274,6 +274,7 @@ class TestMultiUserBasicValidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode, errors = validate_configuration(settings)
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
@@ -372,6 +373,7 @@ class TestOAuthSingleAudienceValidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode, errors = validate_configuration(settings)
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
@@ -459,6 +461,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.vector_sync_enabled is True
|
assert settings.vector_sync_enabled is True
|
||||||
|
|
||||||
@@ -474,6 +477,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.vector_sync_enabled is True
|
assert settings.vector_sync_enabled is True
|
||||||
|
|
||||||
@@ -490,6 +494,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.enable_offline_access is True
|
assert settings.enable_offline_access is True
|
||||||
|
|
||||||
@@ -506,6 +511,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.enable_offline_access is True
|
assert settings.enable_offline_access is True
|
||||||
|
|
||||||
@@ -525,6 +531,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Semantic search enabled
|
# Semantic search enabled
|
||||||
@@ -549,6 +556,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Semantic search enabled
|
# Semantic search enabled
|
||||||
@@ -572,6 +580,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Semantic search NOT enabled
|
# Semantic search NOT enabled
|
||||||
@@ -593,6 +602,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Should use new name value (true)
|
# Should use new name value (true)
|
||||||
@@ -612,6 +622,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Should use new name value (true)
|
# Should use new name value (true)
|
||||||
@@ -637,6 +648,7 @@ class TestConfigurationConsolidation:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode, errors = validate_configuration(settings)
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
@@ -671,6 +683,7 @@ class TestExplicitModeSelection:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
|
|
||||||
@@ -688,6 +701,7 @@ class TestExplicitModeSelection:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
|
|
||||||
@@ -705,6 +719,7 @@ class TestExplicitModeSelection:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
|
|
||||||
@@ -722,6 +737,7 @@ class TestExplicitModeSelection:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Should raise ValueError with clear message
|
# Should raise ValueError with clear message
|
||||||
@@ -747,6 +763,7 @@ class TestExplicitModeSelection:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
|
|
||||||
@@ -765,6 +782,7 @@ class TestExplicitModeSelection:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
|
|
||||||
@@ -782,6 +800,7 @@ class TestExplicitModeSelection:
|
|||||||
):
|
):
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import os
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import _reload_config, get_document_processor_config
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
@@ -12,18 +14,16 @@ class TestDocumentProcessorConfig:
|
|||||||
|
|
||||||
def test_config_disabled_by_default(self):
|
def test_config_disabled_by_default(self):
|
||||||
"""Test that document processing is disabled by default."""
|
"""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)
|
os.environ.pop("ENABLE_DOCUMENT_PROCESSING", None)
|
||||||
|
_reload_config()
|
||||||
config = get_document_processor_config()
|
config = get_document_processor_config()
|
||||||
assert config["enabled"] is False
|
assert config["enabled"] is False
|
||||||
|
|
||||||
def test_config_enabled(self):
|
def test_config_enabled(self):
|
||||||
"""Test enabling document processing."""
|
"""Test enabling document processing."""
|
||||||
from nextcloud_mcp_server.config import get_document_processor_config
|
|
||||||
|
|
||||||
os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true"
|
os.environ["ENABLE_DOCUMENT_PROCESSING"] = "true"
|
||||||
try:
|
try:
|
||||||
|
_reload_config()
|
||||||
config = get_document_processor_config()
|
config = get_document_processor_config()
|
||||||
assert config["enabled"] is True
|
assert config["enabled"] is True
|
||||||
finally:
|
finally:
|
||||||
@@ -31,8 +31,6 @@ class TestDocumentProcessorConfig:
|
|||||||
|
|
||||||
def test_unstructured_processor_config(self):
|
def test_unstructured_processor_config(self):
|
||||||
"""Test Unstructured processor configuration."""
|
"""Test Unstructured processor configuration."""
|
||||||
from nextcloud_mcp_server.config import get_document_processor_config
|
|
||||||
|
|
||||||
os.environ["ENABLE_UNSTRUCTURED"] = "true"
|
os.environ["ENABLE_UNSTRUCTURED"] = "true"
|
||||||
os.environ["UNSTRUCTURED_API_URL"] = "http://test:8000"
|
os.environ["UNSTRUCTURED_API_URL"] = "http://test:8000"
|
||||||
os.environ["UNSTRUCTURED_STRATEGY"] = "hi_res"
|
os.environ["UNSTRUCTURED_STRATEGY"] = "hi_res"
|
||||||
@@ -40,6 +38,7 @@ class TestDocumentProcessorConfig:
|
|||||||
os.environ["UNSTRUCTURED_TIMEOUT"] = "60"
|
os.environ["UNSTRUCTURED_TIMEOUT"] = "60"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_reload_config()
|
||||||
config = get_document_processor_config()
|
config = get_document_processor_config()
|
||||||
assert "unstructured" in config["processors"]
|
assert "unstructured" in config["processors"]
|
||||||
unst_config = config["processors"]["unstructured"]
|
unst_config = config["processors"]["unstructured"]
|
||||||
@@ -56,13 +55,12 @@ class TestDocumentProcessorConfig:
|
|||||||
|
|
||||||
def test_tesseract_processor_config(self):
|
def test_tesseract_processor_config(self):
|
||||||
"""Test Tesseract processor configuration."""
|
"""Test Tesseract processor configuration."""
|
||||||
from nextcloud_mcp_server.config import get_document_processor_config
|
|
||||||
|
|
||||||
os.environ["ENABLE_TESSERACT"] = "true"
|
os.environ["ENABLE_TESSERACT"] = "true"
|
||||||
os.environ["TESSERACT_LANG"] = "eng+deu"
|
os.environ["TESSERACT_LANG"] = "eng+deu"
|
||||||
os.environ["TESSERACT_CMD"] = "/usr/local/bin/tesseract"
|
os.environ["TESSERACT_CMD"] = "/usr/local/bin/tesseract"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_reload_config()
|
||||||
config = get_document_processor_config()
|
config = get_document_processor_config()
|
||||||
assert "tesseract" in config["processors"]
|
assert "tesseract" in config["processors"]
|
||||||
tess_config = config["processors"]["tesseract"]
|
tess_config = config["processors"]["tesseract"]
|
||||||
@@ -75,8 +73,6 @@ class TestDocumentProcessorConfig:
|
|||||||
|
|
||||||
def test_custom_processor_config(self):
|
def test_custom_processor_config(self):
|
||||||
"""Test custom processor configuration."""
|
"""Test custom processor configuration."""
|
||||||
from nextcloud_mcp_server.config import get_document_processor_config
|
|
||||||
|
|
||||||
os.environ["ENABLE_CUSTOM_PROCESSOR"] = "true"
|
os.environ["ENABLE_CUSTOM_PROCESSOR"] = "true"
|
||||||
os.environ["CUSTOM_PROCESSOR_NAME"] = "my_ocr"
|
os.environ["CUSTOM_PROCESSOR_NAME"] = "my_ocr"
|
||||||
os.environ["CUSTOM_PROCESSOR_URL"] = "http://localhost:9000/process"
|
os.environ["CUSTOM_PROCESSOR_URL"] = "http://localhost:9000/process"
|
||||||
@@ -85,6 +81,7 @@ class TestDocumentProcessorConfig:
|
|||||||
os.environ["CUSTOM_PROCESSOR_TYPES"] = "application/pdf,image/jpeg"
|
os.environ["CUSTOM_PROCESSOR_TYPES"] = "application/pdf,image/jpeg"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_reload_config()
|
||||||
config = get_document_processor_config()
|
config = get_document_processor_config()
|
||||||
assert "custom" in config["processors"]
|
assert "custom" in config["processors"]
|
||||||
custom_config = config["processors"]["custom"]
|
custom_config = config["processors"]["custom"]
|
||||||
@@ -104,13 +101,12 @@ class TestDocumentProcessorConfig:
|
|||||||
|
|
||||||
def test_multiple_processors(self):
|
def test_multiple_processors(self):
|
||||||
"""Test configuration with multiple processors enabled."""
|
"""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_DOCUMENT_PROCESSING"] = "true"
|
||||||
os.environ["ENABLE_UNSTRUCTURED"] = "true"
|
os.environ["ENABLE_UNSTRUCTURED"] = "true"
|
||||||
os.environ["ENABLE_TESSERACT"] = "true"
|
os.environ["ENABLE_TESSERACT"] = "true"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
_reload_config()
|
||||||
config = get_document_processor_config()
|
config = get_document_processor_config()
|
||||||
assert config["enabled"] is True
|
assert config["enabled"] is True
|
||||||
assert "unstructured" in config["processors"]
|
assert "unstructured" in config["processors"]
|
||||||
@@ -122,14 +118,14 @@ class TestDocumentProcessorConfig:
|
|||||||
|
|
||||||
def test_default_processor_selection(self):
|
def test_default_processor_selection(self):
|
||||||
"""Test default processor configuration."""
|
"""Test default processor configuration."""
|
||||||
from nextcloud_mcp_server.config import get_document_processor_config
|
|
||||||
|
|
||||||
os.environ.pop("DOCUMENT_PROCESSOR", None)
|
os.environ.pop("DOCUMENT_PROCESSOR", None)
|
||||||
|
_reload_config()
|
||||||
config = get_document_processor_config()
|
config = get_document_processor_config()
|
||||||
assert config["default_processor"] == "unstructured"
|
assert config["default_processor"] == "unstructured"
|
||||||
|
|
||||||
os.environ["DOCUMENT_PROCESSOR"] = "tesseract"
|
os.environ["DOCUMENT_PROCESSOR"] = "tesseract"
|
||||||
try:
|
try:
|
||||||
|
_reload_config()
|
||||||
config = get_document_processor_config()
|
config = get_document_processor_config()
|
||||||
assert config["default_processor"] == "tesseract"
|
assert config["default_processor"] == "tesseract"
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import certifi
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
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
|
from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport
|
||||||
|
|
||||||
|
|
||||||
@@ -50,7 +55,7 @@ class TestGetNextcloudSSLVerify:
|
|||||||
"NEXTCLOUD_VERIFY_SSL": "true",
|
"NEXTCLOUD_VERIFY_SSL": "true",
|
||||||
}
|
}
|
||||||
with patch.dict(os.environ, env, clear=False):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
# Clear any cached settings
|
_reload_config()
|
||||||
result = get_nextcloud_ssl_verify()
|
result = get_nextcloud_ssl_verify()
|
||||||
assert result is True
|
assert result is True
|
||||||
|
|
||||||
@@ -110,12 +115,14 @@ class TestGetSettingsSSLEnvVars:
|
|||||||
def test_verify_ssl_env_true(self):
|
def test_verify_ssl_env_true(self):
|
||||||
env = {"NEXTCLOUD_VERIFY_SSL": "true"}
|
env = {"NEXTCLOUD_VERIFY_SSL": "true"}
|
||||||
with patch.dict(os.environ, env, clear=False):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.nextcloud_verify_ssl is True
|
assert settings.nextcloud_verify_ssl is True
|
||||||
|
|
||||||
def test_verify_ssl_env_false(self):
|
def test_verify_ssl_env_false(self):
|
||||||
env = {"NEXTCLOUD_VERIFY_SSL": "false"}
|
env = {"NEXTCLOUD_VERIFY_SSL": "false"}
|
||||||
with patch.dict(os.environ, env, clear=False):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.nextcloud_verify_ssl is False
|
assert settings.nextcloud_verify_ssl is False
|
||||||
|
|
||||||
@@ -123,6 +130,7 @@ class TestGetSettingsSSLEnvVars:
|
|||||||
with patch.dict(os.environ, {}, clear=False):
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
# Remove NEXTCLOUD_VERIFY_SSL if it exists
|
# Remove NEXTCLOUD_VERIFY_SSL if it exists
|
||||||
os.environ.pop("NEXTCLOUD_VERIFY_SSL", None)
|
os.environ.pop("NEXTCLOUD_VERIFY_SSL", None)
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.nextcloud_verify_ssl is True
|
assert settings.nextcloud_verify_ssl is True
|
||||||
|
|
||||||
@@ -133,6 +141,7 @@ class TestGetSettingsSSLEnvVars:
|
|||||||
)
|
)
|
||||||
env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)}
|
env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)}
|
||||||
with patch.dict(os.environ, env, clear=False):
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
_reload_config()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
assert settings.nextcloud_ca_bundle == str(ca_file)
|
assert settings.nextcloud_ca_bundle == str(ca_file)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user