Re-upload: CI infrastructure issue resolved, all tests verified passing
This commit is contained in:
17
envschema/__init__.py
Normal file
17
envschema/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""EnvSchema - Environment variable schema validation tool."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
from envschema.schema import Schema, EnvVar, EnvVarType
|
||||
from envschema.core import ValidationEngine, ValidationResult, ValidationError
|
||||
from envschema.loader import EnvLoader
|
||||
|
||||
__all__ = [
|
||||
"Schema",
|
||||
"EnvVar",
|
||||
"EnvVarType",
|
||||
"ValidationEngine",
|
||||
"ValidationResult",
|
||||
"ValidationError",
|
||||
"EnvLoader",
|
||||
]
|
||||
151
envschema/cli.py
Normal file
151
envschema/cli.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""CLI interface for EnvSchema."""
|
||||
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from envschema import __version__
|
||||
from envschema.schema import load_schema_from_file
|
||||
from envschema.core import validate_environment
|
||||
from envschema.generator import generate_env_example_to_file
|
||||
from envschema.formatters import format_result
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option(version=__version__)
|
||||
def cli():
|
||||
"""EnvSchema - Validate environment variables against a schema."""
|
||||
pass
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("schema", type=click.Path(exists=True))
|
||||
@click.option(
|
||||
"--file",
|
||||
"-f",
|
||||
"env_file",
|
||||
type=click.Path(),
|
||||
help="Path to .env file to validate",
|
||||
)
|
||||
@click.option(
|
||||
"--env/--no-env",
|
||||
default=True,
|
||||
help="Include os.environ in validation",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"-o",
|
||||
"output_format",
|
||||
type=click.Choice(["text", "json"]),
|
||||
default="text",
|
||||
help="Output format",
|
||||
)
|
||||
@click.option(
|
||||
"--ci",
|
||||
is_flag=True,
|
||||
help="CI mode (cleaner output)",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
help="Fail on warnings",
|
||||
)
|
||||
def validate(schema, env_file, env, output_format, ci, strict):
|
||||
"""Validate environment variables against a schema.
|
||||
|
||||
SCHEMA is the path to the schema file (JSON or YAML).
|
||||
"""
|
||||
try:
|
||||
result = validate_environment(
|
||||
schema_path=schema,
|
||||
env_file=env_file,
|
||||
use_environment=env,
|
||||
)
|
||||
|
||||
output = format_result(
|
||||
result,
|
||||
output_format=output_format,
|
||||
color=not ci,
|
||||
ci_mode=ci,
|
||||
)
|
||||
click.echo(output)
|
||||
|
||||
if not result.is_valid:
|
||||
sys.exit(1)
|
||||
|
||||
if strict and result.warnings:
|
||||
click.echo("Warnings found in strict mode", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(2)
|
||||
except ValueError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("schema", type=click.Path(exists=True))
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
"output_file",
|
||||
type=click.Path(),
|
||||
help="Output file path (default: .env.example)",
|
||||
)
|
||||
@click.option(
|
||||
"--no-comments",
|
||||
is_flag=True,
|
||||
help="Don't include description comments",
|
||||
)
|
||||
def generate(schema, output_file, no_comments):
|
||||
"""Generate .env.example from a schema.
|
||||
|
||||
SCHEMA is the path to the schema file (JSON or YAML).
|
||||
"""
|
||||
try:
|
||||
schema_obj = load_schema_from_file(schema)
|
||||
|
||||
output_path = output_file or ".env.example"
|
||||
|
||||
generate_env_example_to_file(
|
||||
schema_obj,
|
||||
output_path,
|
||||
include_descriptions=not no_comments,
|
||||
)
|
||||
|
||||
click.echo(f"Generated {output_path}")
|
||||
|
||||
except FileNotFoundError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(2)
|
||||
except ValueError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("schema", type=click.Path(exists=True))
|
||||
def check(schema):
|
||||
"""Check if a schema file is valid.
|
||||
|
||||
SCHEMA is the path to the schema file (JSON or YAML).
|
||||
"""
|
||||
try:
|
||||
schema_obj = load_schema_from_file(schema)
|
||||
click.echo(f"Schema is valid (version: {schema_obj.version})")
|
||||
click.echo(f"Found {len(schema_obj.envvars)} environment variables")
|
||||
sys.exit(0)
|
||||
except FileNotFoundError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(2)
|
||||
except ValueError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
162
envschema/core.py
Normal file
162
envschema/core.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""Core validation engine for environment variables."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from envschema.schema import Schema
|
||||
from envschema.loader import EnvLoader
|
||||
from envschema.validators import validate_value
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationError:
|
||||
"""Represents a validation error for a specific variable."""
|
||||
|
||||
var_name: str
|
||||
error_type: str
|
||||
message: str
|
||||
value: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON output."""
|
||||
return {
|
||||
"var_name": self.var_name,
|
||||
"error_type": self.error_type,
|
||||
"message": self.message,
|
||||
"value": self.value,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of schema validation."""
|
||||
|
||||
is_valid: bool
|
||||
missing_required: list[str] = field(default_factory=list)
|
||||
type_errors: list[ValidationError] = field(default_factory=list)
|
||||
pattern_errors: list[ValidationError] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary for JSON output."""
|
||||
return {
|
||||
"is_valid": self.is_valid,
|
||||
"missing_required": self.missing_required,
|
||||
"type_errors": [e.to_dict() for e in self.type_errors],
|
||||
"pattern_errors": [e.to_dict() for e in self.pattern_errors],
|
||||
"warnings": self.warnings,
|
||||
}
|
||||
|
||||
|
||||
class ValidationEngine:
|
||||
"""Engine for validating environment variables against a schema."""
|
||||
|
||||
def __init__(self, schema: Schema):
|
||||
"""Initialize the validation engine.
|
||||
|
||||
Args:
|
||||
schema: The schema to validate against.
|
||||
"""
|
||||
self.schema = schema
|
||||
|
||||
def validate(self, env_vars: dict[str, str]) -> ValidationResult:
|
||||
"""Validate environment variables against the schema.
|
||||
|
||||
Args:
|
||||
env_vars: Dictionary of environment variable names to values.
|
||||
|
||||
Returns:
|
||||
ValidationResult with all errors and warnings.
|
||||
"""
|
||||
result = ValidationResult(is_valid=True)
|
||||
|
||||
self._check_required_vars(env_vars, result)
|
||||
self._validate_types(env_vars, result)
|
||||
self._check_extra_vars(env_vars, result)
|
||||
|
||||
result.is_valid = (
|
||||
len(result.missing_required) == 0
|
||||
and len(result.type_errors) == 0
|
||||
and len(result.pattern_errors) == 0
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
def _check_required_vars(self, env_vars: dict[str, str], result: ValidationResult) -> None:
|
||||
"""Check for missing required variables.
|
||||
|
||||
Args:
|
||||
env_vars: Environment variables.
|
||||
result: Validation result to update.
|
||||
"""
|
||||
required_vars = self.schema.get_required_vars()
|
||||
env_keys_upper = {k.upper() for k in env_vars.keys()}
|
||||
|
||||
for var in required_vars:
|
||||
if var.name.upper() not in env_keys_upper:
|
||||
result.missing_required.append(var.name)
|
||||
result.is_valid = False
|
||||
|
||||
def _validate_types(self, env_vars: dict[str, str], result: ValidationResult) -> None:
|
||||
"""Validate types of environment variables.
|
||||
|
||||
Args:
|
||||
env_vars: Environment variables.
|
||||
result: Validation result to update.
|
||||
"""
|
||||
env_vars_upper = {k.upper(): v for k, v in env_vars.items()}
|
||||
for var in self.schema.envvars:
|
||||
value = env_vars_upper.get(var.name.upper())
|
||||
|
||||
if value is None and var.default is not None:
|
||||
continue
|
||||
|
||||
if value is not None:
|
||||
is_valid, error = validate_value(value, var.type, var.pattern)
|
||||
if not is_valid and error:
|
||||
result.type_errors.append(
|
||||
ValidationError(
|
||||
var_name=var.name,
|
||||
error_type="type_mismatch",
|
||||
message=error.message,
|
||||
value=error.value,
|
||||
)
|
||||
)
|
||||
result.is_valid = False
|
||||
|
||||
def _check_extra_vars(self, env_vars: dict[str, str], result: ValidationResult) -> None:
|
||||
"""Check for extra variables not in schema (warning only).
|
||||
|
||||
Args:
|
||||
env_vars: Environment variables.
|
||||
result: Validation result to update.
|
||||
"""
|
||||
schema_keys_upper = {v.name.upper() for v in self.schema.envvars}
|
||||
for key in env_vars.keys():
|
||||
if key.upper() not in schema_keys_upper:
|
||||
result.warnings.append(f"Unknown environment variable: {key}")
|
||||
|
||||
|
||||
def validate_environment(
|
||||
schema_path: str,
|
||||
env_file: Optional[str] = None,
|
||||
use_environment: bool = True,
|
||||
) -> ValidationResult:
|
||||
"""Convenience function to validate environment against a schema file.
|
||||
|
||||
Args:
|
||||
schema_path: Path to the schema file (JSON or YAML).
|
||||
env_file: Optional path to .env file.
|
||||
use_environment: Whether to include os.environ.
|
||||
|
||||
Returns:
|
||||
ValidationResult with validation status.
|
||||
"""
|
||||
from envschema.schema import load_schema_from_file
|
||||
|
||||
schema = load_schema_from_file(schema_path)
|
||||
loader = EnvLoader(file_path=env_file, use_environment=use_environment)
|
||||
env_vars = loader.load()
|
||||
|
||||
engine = ValidationEngine(schema)
|
||||
return engine.validate(env_vars)
|
||||
132
envschema/formatters.py
Normal file
132
envschema/formatters.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Output formatters for validation results."""
|
||||
|
||||
import json
|
||||
|
||||
from envschema.core import ValidationResult
|
||||
|
||||
|
||||
class TextFormatter:
|
||||
"""Formatter for human-readable text output."""
|
||||
|
||||
RED = "\033[91m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
RESET = "\033[0m"
|
||||
BOLD = "\033[1m"
|
||||
|
||||
@staticmethod
|
||||
def format(result: ValidationResult, color: bool = True, ci_mode: bool = False) -> str:
|
||||
"""Format validation result as text.
|
||||
|
||||
Args:
|
||||
result: Validation result to format.
|
||||
color: Whether to use ANSI colors.
|
||||
ci_mode: CI mode (cleaner output without special chars).
|
||||
|
||||
Returns:
|
||||
Formatted text.
|
||||
"""
|
||||
lines = []
|
||||
|
||||
if ci_mode:
|
||||
return TextFormatter._format_ci(result)
|
||||
|
||||
if result.is_valid:
|
||||
lines.append(f"{TextFormatter._color(TextFormatter.GREEN, '✓', color)} Validation passed")
|
||||
if result.warnings:
|
||||
lines.append("")
|
||||
lines.append("Warnings:")
|
||||
for warning in result.warnings:
|
||||
lines.append(f" {TextFormatter._color(TextFormatter.YELLOW, '⚠', color)} {warning}")
|
||||
else:
|
||||
lines.append(f"{TextFormatter._color(TextFormatter.RED, '✗', color)} Validation failed")
|
||||
lines.append("")
|
||||
|
||||
if result.missing_required:
|
||||
lines.append("Missing required variables:")
|
||||
for var in result.missing_required:
|
||||
lines.append(f" {TextFormatter._color(TextFormatter.RED, '✗', color)} {var}")
|
||||
lines.append("")
|
||||
|
||||
if result.type_errors:
|
||||
lines.append("Type errors:")
|
||||
for error in result.type_errors:
|
||||
lines.append(f" {TextFormatter._color(TextFormatter.RED, '✗', color)} {error.var_name}: {error.message}")
|
||||
lines.append("")
|
||||
|
||||
if result.pattern_errors:
|
||||
lines.append("Pattern errors:")
|
||||
for error in result.pattern_errors:
|
||||
lines.append(f" {TextFormatter._color(TextFormatter.RED, '✗', color)} {error.var_name}: {error.message}")
|
||||
lines.append("")
|
||||
|
||||
if result.warnings:
|
||||
lines.append("Warnings:")
|
||||
for warning in result.warnings:
|
||||
lines.append(f" {TextFormatter._color(TextFormatter.YELLOW, '⚠', color)} {warning}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _format_ci(result: ValidationResult) -> str:
|
||||
"""Format result for CI mode."""
|
||||
lines = []
|
||||
|
||||
if result.is_valid:
|
||||
lines.append("Validation passed")
|
||||
else:
|
||||
lines.append("Validation failed")
|
||||
|
||||
if result.missing_required:
|
||||
lines.append("Missing required variables: " + ", ".join(result.missing_required))
|
||||
|
||||
if result.type_errors:
|
||||
for error in result.type_errors:
|
||||
lines.append(f"{error.var_name}: {error.message}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _color(color_code: str, text: str, use_color: bool) -> str:
|
||||
"""Apply color to text."""
|
||||
if use_color:
|
||||
return f"{color_code}{text}{TextFormatter.RESET}"
|
||||
return text
|
||||
|
||||
|
||||
class JsonFormatter:
|
||||
"""Formatter for JSON output."""
|
||||
|
||||
@staticmethod
|
||||
def format(result: ValidationResult) -> str:
|
||||
"""Format validation result as JSON.
|
||||
|
||||
Args:
|
||||
result: Validation result to format.
|
||||
|
||||
Returns:
|
||||
JSON string.
|
||||
"""
|
||||
return json.dumps(result.to_dict(), indent=2)
|
||||
|
||||
|
||||
def format_result(
|
||||
result: ValidationResult,
|
||||
output_format: str = "text",
|
||||
color: bool = True,
|
||||
ci_mode: bool = False,
|
||||
) -> str:
|
||||
"""Format a validation result.
|
||||
|
||||
Args:
|
||||
result: Validation result to format.
|
||||
output_format: Output format ('text' or 'json').
|
||||
color: Whether to use colors (text format only).
|
||||
ci_mode: CI mode (text format only).
|
||||
|
||||
Returns:
|
||||
Formatted output string.
|
||||
"""
|
||||
if output_format == "json":
|
||||
return JsonFormatter.format(result)
|
||||
return TextFormatter.format(result, color=color, ci_mode=ci_mode)
|
||||
54
envschema/generator.py
Normal file
54
envschema/generator.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Generator for .env.example files from schema definitions."""
|
||||
|
||||
from envschema.schema import Schema
|
||||
|
||||
|
||||
def generate_env_example(schema: Schema, include_descriptions: bool = True) -> str:
|
||||
"""Generate an .env.example file content from a schema.
|
||||
|
||||
Args:
|
||||
schema: The schema to generate from.
|
||||
include_descriptions: Whether to include description comments.
|
||||
|
||||
Returns:
|
||||
Formatted .env.example content.
|
||||
"""
|
||||
lines = []
|
||||
|
||||
lines.append("# Environment Variables Schema")
|
||||
if schema.version:
|
||||
lines.append(f"# Version: {schema.version}")
|
||||
lines.append("")
|
||||
|
||||
for var in schema.envvars:
|
||||
if include_descriptions and var.description:
|
||||
lines.append(f"# {var.description}")
|
||||
|
||||
if var.required:
|
||||
lines.append("# REQUIRED")
|
||||
elif var.default is not None:
|
||||
lines.append(f"# Default: {var.default}")
|
||||
|
||||
if var.required:
|
||||
lines.append(f"{var.name}=")
|
||||
elif var.default is not None:
|
||||
lines.append(f"{var.name}={var.default}")
|
||||
else:
|
||||
lines.append(f"{var.name}=")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_env_example_to_file(schema: Schema, output_path: str, include_descriptions: bool = True) -> None:
|
||||
"""Generate and write an .env.example file.
|
||||
|
||||
Args:
|
||||
schema: The schema to generate from.
|
||||
output_path: Path to write the .env.example file.
|
||||
include_descriptions: Whether to include description comments.
|
||||
"""
|
||||
content = generate_env_example(schema, include_descriptions)
|
||||
with open(output_path, "w") as f:
|
||||
f.write(content)
|
||||
91
envschema/loader.py
Normal file
91
envschema/loader.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Environment variable loader for .env files and os.environ."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
|
||||
class EnvLoader:
|
||||
"""Load environment variables from .env files or os.environ."""
|
||||
|
||||
def __init__(self, file_path: Optional[str] = None, use_environment: bool = True):
|
||||
"""Initialize the loader.
|
||||
|
||||
Args:
|
||||
file_path: Path to .env file. If None, only uses os.environ.
|
||||
use_environment: Whether to include os.environ as fallback.
|
||||
"""
|
||||
self.file_path = file_path
|
||||
self.use_environment = use_environment
|
||||
self._env_vars: dict[str, str] = {}
|
||||
|
||||
def load(self) -> dict[str, str]:
|
||||
"""Load environment variables.
|
||||
|
||||
Returns:
|
||||
Dictionary of environment variable names to values.
|
||||
"""
|
||||
self._env_vars = {}
|
||||
|
||||
if self.file_path:
|
||||
path = Path(self.file_path)
|
||||
if path.exists():
|
||||
file_values = dotenv_values(str(path))
|
||||
for key, value in file_values.items():
|
||||
if value is not None:
|
||||
self._env_vars[key] = value
|
||||
|
||||
if self.use_environment:
|
||||
for key, value in os.environ.items():
|
||||
if key not in self._env_vars:
|
||||
self._env_vars[key] = value
|
||||
|
||||
return self._env_vars
|
||||
|
||||
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Get an environment variable value.
|
||||
|
||||
Args:
|
||||
key: Variable name.
|
||||
default: Default value if not found.
|
||||
|
||||
Returns:
|
||||
Variable value or default.
|
||||
"""
|
||||
if not self._env_vars:
|
||||
self.load()
|
||||
return self._env_vars.get(key, default)
|
||||
|
||||
def get_raw(self) -> dict[str, str]:
|
||||
"""Get all loaded variables as a raw dictionary.
|
||||
|
||||
Returns:
|
||||
Dictionary of all loaded environment variables.
|
||||
"""
|
||||
if not self._env_vars:
|
||||
self.load()
|
||||
return self._env_vars.copy()
|
||||
|
||||
def set(self, key: str, value: str) -> None:
|
||||
"""Set an environment variable value in memory.
|
||||
|
||||
Args:
|
||||
key: Variable name.
|
||||
value: Variable value.
|
||||
"""
|
||||
self._env_vars[key] = value
|
||||
|
||||
|
||||
def load_env_file(file_path: str) -> dict[str, str]:
|
||||
"""Convenience function to load environment from a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to .env file.
|
||||
|
||||
Returns:
|
||||
Dictionary of environment variables.
|
||||
"""
|
||||
loader = EnvLoader(file_path=file_path, use_environment=False)
|
||||
return loader.load()
|
||||
110
envschema/schema.py
Normal file
110
envschema/schema.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Schema models for environment variable definitions."""
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class EnvVarType(str, Enum):
|
||||
"""Supported environment variable types."""
|
||||
|
||||
STRING = "str"
|
||||
INTEGER = "int"
|
||||
BOOLEAN = "bool"
|
||||
LIST = "list"
|
||||
|
||||
|
||||
class EnvVar(BaseModel):
|
||||
"""Definition of a single environment variable."""
|
||||
|
||||
name: str = Field(..., description="Variable name (e.g., DATABASE_URL)")
|
||||
type: EnvVarType = Field(default=EnvVarType.STRING, description="Variable type")
|
||||
required: bool = Field(default=False, description="Whether variable is required")
|
||||
default: Optional[str] = Field(default=None, description="Default value if optional")
|
||||
description: Optional[str] = Field(default=None, description="Variable description")
|
||||
pattern: Optional[str] = Field(default=None, description="Regex pattern for validation")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_must_be_valid_env_var(cls, v: str) -> str:
|
||||
if not v.replace("_", "").replace("-", "").isalnum():
|
||||
raise ValueError("Variable name must contain only alphanumeric characters, underscores, and hyphens")
|
||||
return v.upper()
|
||||
|
||||
|
||||
class Schema(BaseModel):
|
||||
"""Schema containing all environment variable definitions."""
|
||||
|
||||
version: Optional[str] = Field(default="1.0", description="Schema version")
|
||||
envvars: list[EnvVar] = Field(default_factory=list, alias="envVars")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
def get_var(self, name: str) -> Optional[EnvVar]:
|
||||
"""Get an environment variable by name."""
|
||||
name_upper = name.upper()
|
||||
for var in self.envvars:
|
||||
if var.name.upper() == name_upper:
|
||||
return var
|
||||
return None
|
||||
|
||||
def get_required_vars(self) -> list[EnvVar]:
|
||||
"""Get all required environment variables."""
|
||||
return [var for var in self.envvars if var.required]
|
||||
|
||||
|
||||
def load_schema_from_file(file_path: str) -> Schema:
|
||||
"""Load schema from a JSON or YAML file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the schema file
|
||||
|
||||
Returns:
|
||||
Parsed Schema object
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If schema file doesn't exist
|
||||
ValueError: If schema format is invalid
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Schema file not found: {file_path}")
|
||||
|
||||
content = path.read_text()
|
||||
|
||||
if path.suffix.lower() in [".yaml", ".yml"]:
|
||||
return load_yaml_schema(content)
|
||||
elif path.suffix.lower() == ".json":
|
||||
return load_json_schema(content)
|
||||
else:
|
||||
raise ValueError(f"Unsupported schema format: {path.suffix}. Use .json or .yaml")
|
||||
|
||||
|
||||
def load_json_schema(content: str) -> Schema:
|
||||
"""Load schema from JSON content."""
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON schema: {e}")
|
||||
|
||||
try:
|
||||
return Schema.model_validate(data)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid schema structure: {e}")
|
||||
|
||||
|
||||
def load_yaml_schema(content: str) -> Schema:
|
||||
"""Load schema from YAML content."""
|
||||
try:
|
||||
data = yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML schema: {e}")
|
||||
|
||||
try:
|
||||
return Schema.model_validate(data)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid schema structure: {e}")
|
||||
153
envschema/validators.py
Normal file
153
envschema/validators.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Type validators for environment variable values."""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from envschema.schema import EnvVarType
|
||||
|
||||
|
||||
class ValidationError:
|
||||
"""Represents a validation error."""
|
||||
|
||||
def __init__(self, message: str, value: Optional[str] = None):
|
||||
self.message = message
|
||||
self.value = value
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.value is not None:
|
||||
return f"{self.message} (got: {self.value!r})"
|
||||
return self.message
|
||||
|
||||
|
||||
class StringValidator:
|
||||
"""Validator for string type - always passes."""
|
||||
|
||||
@staticmethod
|
||||
def validate(value: Optional[str]) -> tuple[bool, Optional[ValidationError]]:
|
||||
if value is None:
|
||||
return True, None
|
||||
return True, None
|
||||
|
||||
|
||||
class IntegerValidator:
|
||||
"""Validator for integer type."""
|
||||
|
||||
@staticmethod
|
||||
def validate(value: Optional[str]) -> tuple[bool, Optional[ValidationError]]:
|
||||
if value is None:
|
||||
return True, None
|
||||
try:
|
||||
int(value)
|
||||
return True, None
|
||||
except ValueError:
|
||||
return False, ValidationError(
|
||||
"Invalid integer value",
|
||||
value=value
|
||||
)
|
||||
|
||||
|
||||
class BooleanValidator:
|
||||
"""Validator for boolean type."""
|
||||
|
||||
TRUE_VALUES = {"true", "1", "yes", "on"}
|
||||
FALSE_VALUES = {"false", "0", "no", "off"}
|
||||
|
||||
@staticmethod
|
||||
def validate(value: Optional[str]) -> tuple[bool, Optional[ValidationError]]:
|
||||
if value is None:
|
||||
return True, None
|
||||
value_lower = value.lower().strip()
|
||||
if value_lower in BooleanValidator.TRUE_VALUES:
|
||||
return True, None
|
||||
if value_lower in BooleanValidator.FALSE_VALUES:
|
||||
return True, None
|
||||
return False, ValidationError(
|
||||
"Invalid boolean value (expected: true, false, 1, 0, yes, no, on, off)",
|
||||
value=value
|
||||
)
|
||||
|
||||
|
||||
class ListValidator:
|
||||
"""Validator for list type (comma-separated values)."""
|
||||
|
||||
@staticmethod
|
||||
def validate(value: Optional[str]) -> tuple[bool, Optional[ValidationError]]:
|
||||
if value is None:
|
||||
return True, None
|
||||
if "," in value:
|
||||
return True, None
|
||||
return False, ValidationError(
|
||||
"Invalid list value (expected comma-separated values)",
|
||||
value=value
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse(value: str) -> list[str]:
|
||||
"""Parse a comma-separated string into a list.
|
||||
|
||||
Args:
|
||||
value: Comma-separated string.
|
||||
|
||||
Returns:
|
||||
List of values.
|
||||
"""
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
class PatternValidator:
|
||||
"""Validator for pattern/regex validation."""
|
||||
|
||||
@staticmethod
|
||||
def validate(value: Optional[str], pattern: str) -> tuple[bool, Optional[ValidationError]]:
|
||||
if value is None:
|
||||
return True, None
|
||||
try:
|
||||
if re.match(pattern, value):
|
||||
return True, None
|
||||
return False, ValidationError(
|
||||
f"Value does not match pattern: {pattern}",
|
||||
value=value
|
||||
)
|
||||
except re.error:
|
||||
return False, ValidationError(
|
||||
f"Invalid regex pattern: {pattern}",
|
||||
value=value
|
||||
)
|
||||
|
||||
|
||||
def get_validator(var_type: EnvVarType):
|
||||
"""Get the validator class for a given type.
|
||||
|
||||
Args:
|
||||
var_type: The environment variable type.
|
||||
|
||||
Returns:
|
||||
Validator class.
|
||||
"""
|
||||
validators = {
|
||||
EnvVarType.STRING: StringValidator,
|
||||
EnvVarType.INTEGER: IntegerValidator,
|
||||
EnvVarType.BOOLEAN: BooleanValidator,
|
||||
EnvVarType.LIST: ListValidator,
|
||||
}
|
||||
return validators.get(var_type, StringValidator)
|
||||
|
||||
|
||||
def validate_value(value: Optional[str], var_type: EnvVarType, pattern: Optional[str] = None) -> tuple[bool, Optional[ValidationError]]:
|
||||
"""Validate a value against a type and optional pattern.
|
||||
|
||||
Args:
|
||||
value: The value to validate.
|
||||
var_type: The expected type.
|
||||
pattern: Optional regex pattern.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error).
|
||||
"""
|
||||
validator = get_validator(var_type)
|
||||
is_valid, error = validator.validate(value)
|
||||
|
||||
if is_valid and pattern and value is not None:
|
||||
is_valid, error = PatternValidator.validate(value, pattern)
|
||||
|
||||
return is_valid, error
|
||||
Reference in New Issue
Block a user