"""Configuration for pytest-routes."""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal
# Python 3.11+ has tomllib, earlier versions need tomli
if sys.version_info >= (3, 11):
import tomllib
else:
try:
import tomli as tomllib # type: ignore[import-untyped]
except ImportError:
tomllib = None # type: ignore[assignment]
if TYPE_CHECKING:
from pytest_routes.auth.providers import AuthProvider
from pytest_routes.stateful.config import StatefulTestConfig
from pytest_routes.websocket.config import WebSocketTestConfig
@dataclass
class RouteOverride:
"""Per-route configuration overrides.
Allows customizing test behavior for specific routes by path pattern.
All fields are optional; only specified fields will override the base config.
Args:
pattern: Glob pattern to match routes (e.g., "/api/admin/*").
max_examples: Override max examples for matching routes.
timeout: Override timeout for matching routes.
auth: Override authentication provider for matching routes.
skip: If True, skip testing for matching routes.
allowed_status_codes: Override allowed status codes for matching routes.
Example:
>>> override = RouteOverride(
... pattern="/api/admin/*",
... auth=BearerTokenAuth("admin-token"),
... max_examples=50,
... )
"""
pattern: str
max_examples: int | None = None
timeout: float | None = None
auth: AuthProvider | None = None
skip: bool = False
allowed_status_codes: list[int] | None = None
@dataclass
class SchemathesisConfig:
"""Configuration for Schemathesis integration.
Attributes:
enabled: Whether Schemathesis mode is enabled.
schema_path: Path to fetch OpenAPI schema from the app.
validate_responses: Whether to validate response bodies against schema.
stateful: Stateful testing mode ('none', 'links').
checks: List of Schemathesis checks to run.
"""
enabled: bool = False
schema_path: str = "/openapi.json"
validate_responses: bool = True
stateful: str = "none"
checks: list[str] = field(
default_factory=lambda: [
"status_code_conformance",
"content_type_conformance",
"response_schema_conformance",
]
)
@dataclass
class ReportConfig:
"""Configuration for test reporting.
Attributes:
enabled: Whether to generate reports.
output_path: Path to write HTML report.
json_output: Path to write JSON report (None to skip).
title: Title for the HTML report.
include_coverage: Whether to include coverage metrics.
include_timing: Whether to include timing metrics.
theme: Color theme ('light' or 'dark').
"""
enabled: bool = False
output_path: str = "pytest-routes-report.html"
json_output: str | None = None
title: str = "pytest-routes Test Report"
include_coverage: bool = True
include_timing: bool = True
theme: str = "light"
[docs]
@dataclass
class RouteTestConfig:
"""Configuration for route smoke testing."""
# Test execution
max_examples: int = 100
timeout_per_route: float = 30.0
# Route filtering
include_patterns: list[str] = field(default_factory=list)
exclude_patterns: list[str] = field(
default_factory=lambda: ["/health", "/metrics", "/openapi*", "/docs", "/redoc", "/schema"]
)
methods: list[str] = field(default_factory=lambda: ["GET", "POST", "PUT", "PATCH", "DELETE"])
# Generation strategy
strategy: Literal["random", "openapi", "hybrid"] = "hybrid"
seed: int | None = None
# Validation
allowed_status_codes: list[int] = field(default_factory=lambda: list(range(200, 500)))
fail_on_5xx: bool = True
fail_on_validation_error: bool = True
# Response validation
validate_responses: bool = False
response_validators: list[str] = field(default_factory=lambda: ["status_code"])
# Framework hints
framework: Literal["auto", "litestar", "fastapi", "starlette"] | None = "auto"
# Output verbosity
verbose: bool = False
# Authentication
auth: AuthProvider | None = None
# Per-route overrides
route_overrides: list[RouteOverride] = field(default_factory=list)
# Schemathesis integration
schemathesis: SchemathesisConfig = field(default_factory=SchemathesisConfig)
# Reporting
report: ReportConfig = field(default_factory=ReportConfig)
# Stateful testing (lazy import to avoid circular dependency)
stateful: StatefulTestConfig | None = None
# WebSocket testing (lazy import to avoid circular dependency)
websocket: WebSocketTestConfig | None = None
[docs]
def get_override_for_route(self, path: str) -> RouteOverride | None:
"""Get the matching override for a route path.
Finds the first RouteOverride whose pattern matches the given path.
Uses glob-style pattern matching.
Args:
path: The route path to match.
Returns:
The first matching RouteOverride, or None if no match.
"""
import fnmatch
for override in self.route_overrides:
if fnmatch.fnmatch(path, override.pattern):
return override
return None
[docs]
def get_effective_config_for_route(self, path: str) -> dict[str, Any]:
"""Get effective configuration for a specific route.
Merges the base config with any matching route override.
Args:
path: The route path to get config for.
Returns:
Dictionary with effective configuration values.
"""
override = self.get_override_for_route(path)
config: dict[str, Any] = {
"max_examples": self.max_examples,
"timeout": self.timeout_per_route,
"auth": self.auth,
"allowed_status_codes": self.allowed_status_codes,
"skip": False,
}
if override:
if override.max_examples is not None:
config["max_examples"] = override.max_examples
if override.timeout is not None:
config["timeout"] = override.timeout
if override.auth is not None:
config["auth"] = override.auth
if override.allowed_status_codes is not None:
config["allowed_status_codes"] = override.allowed_status_codes
config["skip"] = override.skip
return config
[docs]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> RouteTestConfig:
"""Create config from dictionary (e.g., from pyproject.toml).
Args:
data: Dictionary containing configuration values.
Returns:
RouteTestConfig instance with values from dictionary.
Examples:
>>> config_data = {
... "max_examples": 50,
... "timeout": 30.0,
... "include": ["/api/*"],
... "exclude": ["/health", "/metrics"],
... "methods": ["GET", "POST"],
... "fail_on_5xx": True,
... "allowed_status_codes": [200, 201, 400, 404],
... "seed": 12345,
... "framework": "litestar",
... }
>>> config = RouteTestConfig.from_dict(config_data)
>>> config.max_examples
50
"""
# Use defaults for missing values
defaults = cls()
# Parse auth configuration if present
auth = _parse_auth_config(data.get("auth"))
# Parse route overrides if present
route_overrides = _parse_route_overrides(data.get("routes", []))
# Parse schemathesis configuration if present
schemathesis = _parse_schemathesis_config(data.get("schemathesis", {}))
# Parse report configuration if present
report = _parse_report_config(data.get("report", {}))
# Parse stateful configuration if present
stateful = _parse_stateful_config(data.get("stateful", {}))
# Parse WebSocket configuration if present
websocket = _parse_websocket_config(data.get("websocket", {}))
return cls(
max_examples=data.get("max_examples", defaults.max_examples),
timeout_per_route=data.get("timeout", defaults.timeout_per_route),
include_patterns=data.get("include", defaults.include_patterns),
exclude_patterns=data.get("exclude", defaults.exclude_patterns),
methods=data.get("methods", defaults.methods),
strategy=data.get("strategy", defaults.strategy),
seed=data.get("seed", defaults.seed),
allowed_status_codes=data.get("allowed_status_codes", defaults.allowed_status_codes),
fail_on_5xx=data.get("fail_on_5xx", defaults.fail_on_5xx),
fail_on_validation_error=data.get("fail_on_validation_error", defaults.fail_on_validation_error),
validate_responses=data.get("validate_responses", defaults.validate_responses),
response_validators=data.get("response_validators", defaults.response_validators),
framework=data.get("framework", defaults.framework),
verbose=data.get("verbose", defaults.verbose),
auth=auth,
route_overrides=route_overrides,
schemathesis=schemathesis,
report=report,
stateful=stateful,
websocket=websocket,
)
def _parse_auth_config(auth_data: dict[str, Any] | None) -> AuthProvider | None:
"""Parse authentication configuration from dictionary.
Supports the following auth types:
- bearer_token: Bearer token authentication
- api_key: API key authentication (header or query param)
Args:
auth_data: Authentication configuration dictionary.
Returns:
Configured AuthProvider or None if no auth specified.
Example config in pyproject.toml::
[tool.pytest - routes.auth]
bearer_token = "$API_TOKEN" # From environment variable
# OR
[tool.pytest - routes.auth]
api_key = "my-key"
header_name = "X-API-Key"
# OR
[tool.pytest - routes.auth]
api_key = "$API_KEY"
query_param = "api_key"
"""
if not auth_data:
return None
# Import here to avoid circular imports
from pytest_routes.auth.providers import APIKeyAuth, BearerTokenAuth
# Bearer token auth
if "bearer_token" in auth_data:
return BearerTokenAuth(auth_data["bearer_token"])
# API key auth
if "api_key" in auth_data:
return APIKeyAuth(
auth_data["api_key"],
header_name=auth_data.get("header_name"),
query_param=auth_data.get("query_param"),
)
return None
def _parse_route_overrides(routes_data: list[dict[str, Any]]) -> list[RouteOverride]:
"""Parse route override configurations.
Args:
routes_data: List of route override dictionaries.
Returns:
List of RouteOverride instances.
Example config in pyproject.toml::
[[tool.pytest - routes.routes]]
pattern = "/api/admin/*"
max_examples = 50
skip = false
[[tool.pytest - routes.routes]]
pattern = "/api/internal/*"
skip = true
"""
if not routes_data:
return []
overrides = []
for route_data in routes_data:
if "pattern" not in route_data:
continue
# Parse auth for this route if specified
auth = _parse_auth_config(route_data.get("auth"))
override = RouteOverride(
pattern=route_data["pattern"],
max_examples=route_data.get("max_examples"),
timeout=route_data.get("timeout"),
auth=auth,
skip=route_data.get("skip", False),
allowed_status_codes=route_data.get("allowed_status_codes"),
)
overrides.append(override)
return overrides
def _parse_schemathesis_config(data: dict[str, Any]) -> SchemathesisConfig:
"""Parse Schemathesis configuration from dictionary.
Args:
data: Schemathesis configuration dictionary.
Returns:
SchemathesisConfig instance.
Example config in pyproject.toml::
[tool.pytest - routes.schemathesis] # no spaces around hyphen
enabled = true
schema_path = "/openapi.json"
validate_responses = true
stateful = "links"
checks = ["status_code_conformance", "response_schema_conformance"]
"""
defaults = SchemathesisConfig()
return SchemathesisConfig(
enabled=data.get("enabled", defaults.enabled),
schema_path=data.get("schema_path", defaults.schema_path),
validate_responses=data.get("validate_responses", defaults.validate_responses),
stateful=data.get("stateful", defaults.stateful),
checks=data.get("checks", defaults.checks),
)
def _parse_report_config(data: dict[str, Any]) -> ReportConfig:
"""Parse report configuration from dictionary.
Args:
data: Report configuration dictionary.
Returns:
ReportConfig instance.
Example config in pyproject.toml::
[tool.pytest - routes.report] # no spaces around hyphen
enabled = true
output_path = "pytest-routes-report.html"
json_output = "pytest-routes-report.json"
title = "API Route Tests"
include_coverage = true
include_timing = true
theme = "dark"
"""
defaults = ReportConfig()
return ReportConfig(
enabled=data.get("enabled", defaults.enabled),
output_path=data.get("output_path", defaults.output_path),
json_output=data.get("json_output", defaults.json_output),
title=data.get("title", defaults.title),
include_coverage=data.get("include_coverage", defaults.include_coverage),
include_timing=data.get("include_timing", defaults.include_timing),
theme=data.get("theme", defaults.theme),
)
def _parse_stateful_config(data: dict[str, Any]) -> StatefulTestConfig | None:
"""Parse stateful testing configuration from dictionary.
Args:
data: Stateful testing configuration dictionary.
Returns:
StatefulTestConfig instance or None if not configured.
Example config in pyproject.toml::
[tool.pytest - routes.stateful]
enabled = true
mode = "links"
step_count = 50
max_examples = 100
stateful_recursion_limit = 5
fail_fast = false
collect_coverage = true
[tool.pytest - routes.stateful.link_config]
follow_links = true
max_link_depth = 3
"""
if not data:
return None
# Lazy import to avoid circular dependency
from pytest_routes.stateful.config import StatefulTestConfig
return StatefulTestConfig.from_dict(data)
def _parse_websocket_config(data: dict[str, Any]) -> WebSocketTestConfig | None:
"""Parse WebSocket testing configuration from dictionary.
Args:
data: WebSocket testing configuration dictionary.
Returns:
WebSocketTestConfig instance or None if not configured.
Example config in pyproject.toml::
[tool.pytest - routes.websocket]
enabled = true
max_messages = 10
connection_timeout = 30.0
message_timeout = 10.0
"""
if not data:
return None
# Lazy import to avoid circular dependency
from pytest_routes.websocket.config import WebSocketTestConfig
return WebSocketTestConfig.from_dict(data)
[docs]
def load_config_from_pyproject(path: Path | None = None) -> RouteTestConfig:
"""Load configuration from pyproject.toml [tool.pytest-routes] section.
Args:
path: Path to pyproject.toml file. If None, looks in current working directory.
Returns:
RouteTestConfig instance loaded from file, or defaults if file not found.
Raises:
ImportError: If tomllib/tomli is not available (Python < 3.11 and tomli not installed).
ValueError: If pyproject.toml contains invalid configuration.
Examples:
>>> # Load from default location (./pyproject.toml)
>>> config = load_config_from_pyproject()
>>> # Load from specific path
>>> config = load_config_from_pyproject(Path("/path/to/pyproject.toml"))
"""
if tomllib is None:
msg = "tomllib is not available. For Python < 3.11, install tomli: pip install tomli"
raise ImportError(msg)
if path is None:
path = Path.cwd() / "pyproject.toml"
if not path.exists():
# Return defaults if file doesn't exist
return RouteTestConfig()
try:
with path.open("rb") as f:
data = tomllib.load(f)
except Exception as e:
msg = f"Failed to parse pyproject.toml: {e}"
raise ValueError(msg) from e
# Extract [tool.pytest-routes] section
config_data = data.get("tool", {}).get("pytest-routes", {})
if not config_data:
# No configuration section found, return defaults
return RouteTestConfig()
return RouteTestConfig.from_dict(config_data)
[docs]
def merge_configs(
cli_config: RouteTestConfig | None = None,
file_config: RouteTestConfig | None = None,
) -> RouteTestConfig:
"""Merge CLI and file configs, with CLI taking precedence.
Priority order (highest to lowest):
1. CLI options (if provided and not default)
2. pyproject.toml values
3. Built-in defaults
Args:
cli_config: Configuration from CLI options.
file_config: Configuration from pyproject.toml.
Returns:
Merged configuration with CLI options taking precedence.
Examples:
>>> file_cfg = RouteTestConfig(max_examples=50, seed=123)
>>> cli_cfg = RouteTestConfig(max_examples=100) # Override max_examples
>>> merged = merge_configs(cli_cfg, file_cfg)
>>> merged.max_examples # From CLI
100
>>> merged.seed # From file
123
"""
# Start with defaults
defaults = RouteTestConfig()
# If no configs provided, return defaults
if cli_config is None and file_config is None:
return defaults
# If only file config, return it
if cli_config is None:
return file_config or defaults
# If only CLI config, return it
if file_config is None:
return cli_config
# Helper to check if a list is the "default" value (empty list)
def _is_default_list(value: list, default: list) -> bool:
"""Check if a list value is considered a default."""
# For include_patterns, default is []
# For exclude_patterns, default is ["/health", ...]
# For methods, default is ["GET", "POST", ...]
# Empty list is only "default" for include_patterns
if not value: # Empty list
return not default # Empty is default only if default is also empty
return value == default
# Merge: CLI takes precedence over file, file over defaults
# For each field, use CLI if it differs from default, otherwise use file
return RouteTestConfig(
max_examples=(
cli_config.max_examples if cli_config.max_examples != defaults.max_examples else file_config.max_examples
),
timeout_per_route=(
cli_config.timeout_per_route
if cli_config.timeout_per_route != defaults.timeout_per_route
else file_config.timeout_per_route
),
include_patterns=(
cli_config.include_patterns
if not _is_default_list(cli_config.include_patterns, defaults.include_patterns)
else file_config.include_patterns
),
exclude_patterns=(
cli_config.exclude_patterns
if not _is_default_list(cli_config.exclude_patterns, defaults.exclude_patterns)
else file_config.exclude_patterns
),
methods=(
cli_config.methods if not _is_default_list(cli_config.methods, defaults.methods) else file_config.methods
),
strategy=(cli_config.strategy if cli_config.strategy != defaults.strategy else file_config.strategy),
seed=cli_config.seed if cli_config.seed is not None else file_config.seed,
allowed_status_codes=(
cli_config.allowed_status_codes
if not _is_default_list(cli_config.allowed_status_codes, defaults.allowed_status_codes)
else file_config.allowed_status_codes
),
fail_on_5xx=(
cli_config.fail_on_5xx if cli_config.fail_on_5xx != defaults.fail_on_5xx else file_config.fail_on_5xx
),
fail_on_validation_error=(
cli_config.fail_on_validation_error
if cli_config.fail_on_validation_error != defaults.fail_on_validation_error
else file_config.fail_on_validation_error
),
validate_responses=(
cli_config.validate_responses
if cli_config.validate_responses != defaults.validate_responses
else file_config.validate_responses
),
response_validators=(
cli_config.response_validators
if not _is_default_list(cli_config.response_validators, defaults.response_validators)
else file_config.response_validators
),
framework=(cli_config.framework if cli_config.framework != defaults.framework else file_config.framework),
verbose=cli_config.verbose if cli_config.verbose != defaults.verbose else file_config.verbose,
# Auth: CLI takes precedence if set
auth=cli_config.auth if cli_config.auth is not None else file_config.auth,
# Route overrides: merge both lists (CLI overrides first for pattern matching priority)
route_overrides=cli_config.route_overrides + file_config.route_overrides,
# Schemathesis: CLI takes precedence if enabled
schemathesis=_merge_schemathesis_config(cli_config.schemathesis, file_config.schemathesis),
# Report: CLI takes precedence if enabled
report=_merge_report_config(cli_config.report, file_config.report),
# Stateful: CLI takes precedence if set
stateful=_merge_stateful_config(cli_config.stateful, file_config.stateful),
# WebSocket: CLI takes precedence if set
websocket=_merge_websocket_config(cli_config.websocket, file_config.websocket),
)
def _merge_schemathesis_config(
cli_config: SchemathesisConfig,
file_config: SchemathesisConfig,
) -> SchemathesisConfig:
"""Merge schemathesis configs with CLI taking precedence."""
defaults = SchemathesisConfig()
enabled = cli_config.enabled if cli_config.enabled != defaults.enabled else file_config.enabled
schema_path = cli_config.schema_path if cli_config.schema_path != defaults.schema_path else file_config.schema_path
validate_responses = (
cli_config.validate_responses
if cli_config.validate_responses != defaults.validate_responses
else file_config.validate_responses
)
stateful = cli_config.stateful if cli_config.stateful != defaults.stateful else file_config.stateful
checks = cli_config.checks if cli_config.checks != defaults.checks else file_config.checks
return SchemathesisConfig(
enabled=enabled,
schema_path=schema_path,
validate_responses=validate_responses,
stateful=stateful,
checks=checks,
)
def _merge_report_config(
cli_config: ReportConfig,
file_config: ReportConfig,
) -> ReportConfig:
"""Merge report configs with CLI taking precedence."""
defaults = ReportConfig()
enabled = cli_config.enabled if cli_config.enabled != defaults.enabled else file_config.enabled
output_path = cli_config.output_path if cli_config.output_path != defaults.output_path else file_config.output_path
json_output = cli_config.json_output if cli_config.json_output is not None else file_config.json_output
title = cli_config.title if cli_config.title != defaults.title else file_config.title
include_coverage = (
cli_config.include_coverage
if cli_config.include_coverage != defaults.include_coverage
else file_config.include_coverage
)
include_timing = (
cli_config.include_timing
if cli_config.include_timing != defaults.include_timing
else file_config.include_timing
)
theme = cli_config.theme if cli_config.theme != defaults.theme else file_config.theme
return ReportConfig(
enabled=enabled,
output_path=output_path,
json_output=json_output,
title=title,
include_coverage=include_coverage,
include_timing=include_timing,
theme=theme,
)
def _merge_stateful_config(
cli_config: StatefulTestConfig | None,
file_config: StatefulTestConfig | None,
) -> StatefulTestConfig | None:
"""Merge stateful configs with CLI taking precedence.
Args:
cli_config: Stateful configuration from CLI options.
file_config: Stateful configuration from pyproject.toml.
Returns:
Merged StatefulTestConfig or None if neither is set.
"""
if cli_config is None and file_config is None:
return None
if cli_config is None:
return file_config
if file_config is None:
return cli_config
# Both are set, merge them using the stateful module's merge function
from pytest_routes.stateful.config import merge_stateful_configs
return merge_stateful_configs(cli_config, file_config)
def _merge_websocket_config(
cli_config: WebSocketTestConfig | None,
file_config: WebSocketTestConfig | None,
) -> WebSocketTestConfig | None:
"""Merge WebSocket configs with CLI taking precedence.
Args:
cli_config: WebSocket configuration from CLI options.
file_config: WebSocket configuration from pyproject.toml.
Returns:
Merged WebSocketTestConfig or None if neither is set.
"""
if cli_config is None and file_config is None:
return None
if cli_config is None:
return file_config
if file_config is None:
return cli_config
# Both are set, merge them using the websocket module's merge function
from pytest_routes.websocket.config import merge_websocket_configs
return merge_websocket_configs(cli_config, file_config)