Re-upload: CI infrastructure issue resolved, all tests verified passing

This commit is contained in:
Developer
2026-03-22 16:48:09 +00:00
parent 71bae33ea9
commit 24b94c12bc
165 changed files with 23945 additions and 436 deletions

17
envschema/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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