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

View File

@@ -0,0 +1,45 @@
{
"version": "1.0",
"envVars": [
{
"name": "DATABASE_URL",
"type": "str",
"required": true,
"description": "PostgreSQL connection string"
},
{
"name": "DATABASE_POOL_SIZE",
"type": "int",
"required": false,
"default": "10",
"description": "Database connection pool size"
},
{
"name": "DEBUG_MODE",
"type": "bool",
"required": false,
"default": "false",
"description": "Enable debug mode"
},
{
"name": "ALLOWED_HOSTS",
"type": "list",
"required": false,
"description": "Comma-separated list of allowed hosts"
},
{
"name": "API_KEY",
"type": "str",
"required": true,
"pattern": "^[a-zA-Z0-9_-]+$",
"description": "API authentication key"
},
{
"name": "LOG_LEVEL",
"type": "str",
"required": false,
"default": "INFO",
"description": "Logging level (DEBUG, INFO, WARNING, ERROR)"
}
]
}

View File

@@ -0,0 +1,38 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install -e ".[dev]"
- name: Run unit tests
run: pytest tests/unit/ -v
- name: Run integration tests
run: pytest tests/integration/ -v
- name: Check code formatting
run: |
pip install ruff
ruff check envschema/
- name: Upload coverage
run: |
pip install pytest-cov
pytest --cov=envschema --cov-report=term-missing

79
envschema_repo/.gitignore vendored Normal file
View File

@@ -0,0 +1,79 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Ruff
.ruff_cache/
# OS
.DS_Store
Thumbs.db

21
envschema_repo/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 EnvSchema Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

201
envschema_repo/README.md Normal file
View File

@@ -0,0 +1,201 @@
# EnvSchema
[![CI](https://7000pct.gitea.bloupla.net/7000pctAUTO/envschema/actions/workflows/ci.yml/badge.svg)](https://7000pct.gitea.bloupla.net/7000pctAUTO/envschema/actions)
[![PyPI version](https://img.shields.io/pypi/v/envschema.svg)](https://pypi.org/project/envschema/)
[![Python versions](https://img.shields.io/pypi/pyversions/envschema.svg)](https://pypi.org/project/envschema/)
[![License](https://img.shields.io/pypi/l/envschema.svg)](https://opensource.org/licenses/MIT)
A CLI tool that validates environment variables against a JSON/YAML schema file. Developers define expected env vars with types, defaults, required flags, and descriptions in a schema. The tool validates actual .env files or runtime environment against this schema, catching type mismatches, missing required vars, and providing helpful error messages.
## Features
- Schema validation with type checking (str, int, bool, list)
- Missing required variable detection
- `.env.example` generation from schema
- CI/CD integration for pre-deployment checks
- Support for JSON and YAML schema formats
- Pattern validation with regex support
## Installation
```bash
pip install envschema
```
Or install from source:
```bash
git clone https://7000pct.gitea.bloupla.net/7000pctAUTO/envschema.git
cd envschema
pip install -e .
```
## Quick Start
1. Create a schema file (`.env.schema.json` or `.env.schema.yaml`):
```json
{
"version": "1.0",
"envVars": [
{
"name": "DATABASE_URL",
"type": "str",
"required": true,
"description": "PostgreSQL connection string"
},
{
"name": "DEBUG_MODE",
"type": "bool",
"required": false,
"default": "false"
}
]
}
```
2. Validate your `.env` file:
```bash
envschema validate .env.schema.json --file .env
```
## CLI Commands
### validate
Validate environment variables against a schema:
```bash
envschema validate SCHEMA [--file PATH] [--env/--no-env] [--format text|json] [--ci] [--strict]
```
Options:
- `SCHEMA`: Path to the schema file (JSON or YAML)
- `--file`, `-f`: Path to .env file to validate
- `--env/--no-env`: Include os.environ in validation (default: true)
- `--format`, `-o`: Output format (text or json, default: text)
- `--ci`: CI mode (cleaner output)
- `--strict`: Fail on warnings
### generate
Generate `.env.example` from a schema:
```bash
envschema generate SCHEMA [--output PATH] [--no-comments]
```
Options:
- `SCHEMA`: Path to the schema file
- `--output`, `-o`: Output file path (default: .env.example)
- `--no-comments`: Don't include description comments
### check
Validate a schema file:
```bash
envschema check SCHEMA
```
## Schema Format
### JSON Schema
```json
{
"version": "1.0",
"envVars": [
{
"name": "VAR_NAME",
"type": "str|int|bool|list",
"required": true|false,
"default": "default_value",
"description": "Variable description",
"pattern": "regex_pattern"
}
]
}
```
### YAML Schema
```yaml
version: "1.0"
envVars:
- name: VAR_NAME
type: str
required: true
default: "value"
description: Variable description
pattern: "^[A-Z]+$"
```
## Supported Types
- `str`: String (always valid)
- `int`: Integer (validates numeric values)
- `bool`: Boolean (true, false, 1, 0, yes, no, on, off)
- `list`: Comma-separated list of values
## Examples
### Validate with environment variables
```bash
export DATABASE_URL="postgres://localhost/mydb"
export DEBUG_MODE="true"
envschema validate schema.json
```
### Validate with .env file
```bash
envschema validate schema.json --file .env
```
### CI/CD Integration
```bash
envschema validate schema.json --file .env --ci --format json
```
Exit codes:
- 0: Validation passed
- 1: Validation failed
- 2: Error (schema not found, invalid format)
### Generate .env.example
```bash
envschema generate schema.json
# Generates .env.example
envschema generate schema.json --output .env.dev
```
## API Reference
```python
from envschema import Schema, ValidationEngine, EnvLoader
# Load schema
schema = Schema.load("schema.json")
# Load environment
loader = EnvLoader(".env")
env_vars = loader.load()
# Validate
engine = ValidationEngine(schema)
result = engine.validate(env_vars)
if not result.is_valid:
print(result.missing_required)
print(result.type_errors)
```
## License
MIT License - see LICENSE file for details.

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",
]

View File

@@ -0,0 +1,152 @@
"""CLI interface for EnvSchema."""
import sys
from pathlib import Path
import click
from envschema import __version__
from envschema.schema import load_schema_from_file, Schema
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()

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, EnvVar
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)

View File

@@ -0,0 +1,133 @@
"""Output formatters for validation results."""
import json
from typing import Optional
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)

View File

@@ -0,0 +1,57 @@
"""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(f"# REQUIRED")
elif var.default is not None:
lines.append(f"# Default: {var.default}")
default_part = f"# {var.default}" if var.default else ""
type_part = f"[{var.type.value}]"
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)

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()

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}")

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(
f"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(
f"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(
f"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

View File

@@ -0,0 +1,49 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "envschema"
version = "0.1.0"
description = "A CLI tool that validates environment variables against a JSON/YAML schema file"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
{name = "EnvSchema Team"}
]
keywords = ["environment", "validation", "schema", "cli", "devops"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"click>=8.1.7",
"pyyaml>=6.0.1",
"python-dotenv>=1.0.0",
"pydantic>=2.5.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
]
[project.scripts]
envschema = "envschema.cli:cli"
[tool.setuptools.packages.find]
where = ["."]
include = ["envschema*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-v --cov=envschema --cov-report=term-missing"

37
envschema_repo/setup.py Normal file
View File

@@ -0,0 +1,37 @@
from setuptools import setup, find_packages
setup(
name="envschema",
version="0.1.0",
description="A CLI tool that validates environment variables against a JSON/YAML schema file",
author="EnvSchema Team",
packages=find_packages(where="."),
package_dir={"": "."},
python_requires=">=3.10",
install_requires=[
"click>=8.1.7",
"pyyaml>=6.0.1",
"python-dotenv>=1.0.0",
"pydantic>=2.5.0",
],
extras_require={
"dev": [
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
]
},
entry_points={
"console_scripts": [
"envschema=envschema.cli:cli",
],
},
classifiers=[
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
)

View File

View File

@@ -0,0 +1,73 @@
import pytest
@pytest.fixture
def sample_har_data():
return {
"log": {
"version": "1.2",
"creator": {"name": "Test", "version": "1.0"},
"entries": [
{
"startedDateTime": "2024-01-01T00:00:00.000Z",
"time": 100,
"request": {
"method": "GET",
"url": "https://api.example.com/users/123",
"headers": [
{"name": "Content-Type", "value": "application/json"},
{"name": "Authorization", "value": "Bearer test_token"},
],
"queryString": [{"name": "include", "value": "profile"}],
"postData": None,
},
"response": {
"status": 200,
"statusText": "OK",
"headers": [
{"name": "Content-Type", "value": "application/json"},
],
"content": {
"mimeType": "application/json",
"text": '{"id": 123, "name": "John Doe", "email": "john@example.com"}',
},
},
},
{
"startedDateTime": "2024-01-01T00:00:01.000Z",
"time": 200,
"request": {
"method": "POST",
"url": "https://api.example.com/users",
"headers": [
{"name": "Content-Type", "value": "application/json"},
],
"queryString": [],
"postData": {
"mimeType": "application/json",
"text": '{"name": "Jane Doe", "email": "jane@example.com"}',
},
},
"response": {
"status": 201,
"statusText": "Created",
"headers": [
{"name": "Content-Type", "value": "application/json"},
],
"content": {
"mimeType": "application/json",
"text": '{"id": 456, "name": "Jane Doe", "email": "jane@example.com", "created_at": "2024-01-01T00:00:01Z"}',
},
},
},
],
}
}
@pytest.fixture
def sample_har_file(tmp_path, sample_har_data):
import json
har_file = tmp_path / "test.har"
har_file.write_text(json.dumps(sample_har_data))
return str(har_file)

View File

@@ -0,0 +1,120 @@
"""Integration tests for CLI commands."""
import json
import os
import tempfile
from pathlib import Path
import pytest
from click.testing import CliRunner
from envschema.cli import cli
@pytest.fixture
def temp_schema_file():
"""Create a temporary schema file."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({
"version": "1.0",
"envVars": [
{"name": "DATABASE_URL", "type": "str", "required": True},
{"name": "DEBUG_MODE", "type": "bool", "required": False, "default": "false"},
{"name": "PORT", "type": "int", "required": False, "default": "8080"},
]
}, f)
temp_path = f.name
yield temp_path
os.unlink(temp_path)
@pytest.fixture
def temp_env_file():
"""Create a temporary .env file."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f:
f.write("DATABASE_URL=postgres://localhost/mydb\n")
f.write("DEBUG_MODE=true\n")
temp_path = f.name
yield temp_path
os.unlink(temp_path)
class TestValidateCommand:
"""Tests for the validate command."""
def test_validate_missing_schema(self):
runner = CliRunner()
result = runner.invoke(cli, ["validate", "/nonexistent/schema.json"])
assert result.exit_code == 2
assert "Error" in result.output
def test_validate_valid_env(self, temp_schema_file, temp_env_file):
runner = CliRunner()
result = runner.invoke(cli, ["validate", temp_schema_file, "--file", temp_env_file, "--no-env"])
assert result.exit_code == 0
def test_validate_missing_required(self, temp_schema_file):
runner = CliRunner()
result = runner.invoke(cli, ["validate", temp_schema_file, "--no-env"])
assert result.exit_code == 1
assert "DATABASE_URL" in result.output
def test_validate_with_json_output(self, temp_schema_file, temp_env_file):
runner = CliRunner()
result = runner.invoke(cli, ["validate", temp_schema_file, "--file", temp_env_file, "--no-env", "--format", "json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["is_valid"] is True
def test_validate_ci_mode(self, temp_schema_file, temp_env_file):
runner = CliRunner()
result = runner.invoke(cli, ["validate", temp_schema_file, "--file", temp_env_file, "--no-env", "--ci"])
assert result.exit_code == 0
assert "" not in result.output
class TestGenerateCommand:
"""Tests for the generate command."""
def test_generate_basic(self, temp_schema_file):
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(cli, ["generate", temp_schema_file])
assert result.exit_code == 0
assert ".env.example" in result.output
def test_generate_to_custom_path(self, temp_schema_file, tmp_path):
runner = CliRunner()
output_path = tmp_path / "custom.env.example"
result = runner.invoke(cli, ["generate", temp_schema_file, "--output", str(output_path)])
assert result.exit_code == 0
assert output_path.read_text()
def test_generate_no_comments(self, temp_schema_file, tmp_path):
runner = CliRunner()
output_path = tmp_path / "no_comments.env"
result = runner.invoke(cli, ["generate", temp_schema_file, "--output", str(output_path), "--no-comments"])
assert result.exit_code == 0
content = output_path.read_text()
assert "description" not in content.lower() or "#" not in content
class TestCheckCommand:
"""Tests for the check command."""
def test_check_valid_schema(self, temp_schema_file):
runner = CliRunner()
result = runner.invoke(cli, ["check", temp_schema_file])
assert result.exit_code == 0
assert "valid" in result.output.lower()
def test_check_invalid_schema(self):
runner = CliRunner()
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
f.write('{"version": "1.0", "envVars": [{"name": "VAR", "type": "invalid_type"}]}')
temp_path = f.name
try:
result = runner.invoke(cli, ["check", temp_path])
assert result.exit_code == 2
finally:
os.unlink(temp_path)

View File

@@ -0,0 +1,183 @@
"""End-to-end integration tests for the full validation flow."""
import json
import os
import tempfile
import pytest
from click.testing import CliRunner
from envschema.cli import cli
from envschema.core import validate_environment
from envschema.generator import generate_env_example
class TestFullValidationFlow:
"""Integration tests for complete validation workflows."""
def test_json_schema_with_valid_env(self):
"""Test validating a valid .env against a JSON schema."""
with tempfile.TemporaryDirectory() as tmpdir:
schema_path = os.path.join(tmpdir, "schema.json")
env_path = os.path.join(tmpdir, ".env")
schema_data = {
"version": "1.0",
"envVars": [
{"name": "DATABASE_URL", "type": "str", "required": True},
{"name": "DEBUG", "type": "bool", "required": False, "default": "false"},
{"name": "PORT", "type": "int", "required": False, "default": "8080"},
{"name": "ALLOWED_HOSTS", "type": "list", "required": False},
]
}
with open(schema_path, "w") as f:
json.dump(schema_data, f)
with open(env_path, "w") as f:
f.write("DATABASE_URL=postgres://localhost/mydb\n")
f.write("DEBUG=true\n")
f.write("PORT=3000\n")
f.write("ALLOWED_HOSTS=localhost,127.0.0.1\n")
runner = CliRunner()
result = runner.invoke(cli, ["validate", schema_path, "--file", env_path, "--no-env"])
assert result.exit_code == 0
def test_json_schema_with_invalid_types(self):
"""Test that type mismatches are caught."""
with tempfile.TemporaryDirectory() as tmpdir:
schema_path = os.path.join(tmpdir, "schema.json")
env_path = os.path.join(tmpdir, ".env")
schema_data = {
"version": "1.0",
"envVars": [
{"name": "PORT", "type": "int", "required": True},
]
}
with open(schema_path, "w") as f:
json.dump(schema_data, f)
with open(env_path, "w") as f:
f.write("PORT=not_a_number\n")
runner = CliRunner()
result = runner.invoke(cli, ["validate", schema_path, "--file", env_path, "--no-env"])
assert result.exit_code == 1
assert "PORT" in result.output
def test_missing_required_variables(self):
"""Test that missing required variables are reported."""
with tempfile.TemporaryDirectory() as tmpdir:
schema_path = os.path.join(tmpdir, "schema.json")
env_path = os.path.join(tmpdir, ".env")
schema_data = {
"version": "1.0",
"envVars": [
{"name": "REQUIRED_VAR1", "type": "str", "required": True},
{"name": "REQUIRED_VAR2", "type": "str", "required": True},
{"name": "OPTIONAL_VAR", "type": "str", "required": False},
]
}
with open(schema_path, "w") as f:
json.dump(schema_data, f)
with open(env_path, "w") as f:
f.write("REQUIRED_VAR1=value1\n")
runner = CliRunner()
result = runner.invoke(cli, ["validate", schema_path, "--file", env_path, "--no-env"])
assert result.exit_code == 1
assert "REQUIRED_VAR2" in result.output
def test_generate_and_validate_flow(self):
"""Test generating .env.example and then validating it."""
with tempfile.TemporaryDirectory() as tmpdir:
schema_path = os.path.join(tmpdir, "schema.json")
example_path = os.path.join(tmpdir, ".env.example")
schema_data = {
"version": "1.0",
"envVars": [
{"name": "DATABASE_URL", "type": "str", "required": True, "description": "Database connection string"},
{"name": "DEBUG", "type": "bool", "required": False, "default": "false", "description": "Enable debug mode"},
{"name": "PORT", "type": "int", "required": False, "default": "8080", "description": "Server port"},
]
}
with open(schema_path, "w") as f:
json.dump(schema_data, f)
runner = CliRunner()
result = runner.invoke(cli, ["generate", schema_path, "--output", example_path])
assert result.exit_code == 0
with open(example_path, "r") as f:
content = f.read()
assert "DATABASE_URL=" in content
assert "DEBUG=false" in content
assert "PORT=8080" in content
assert "Database connection string" in content
class TestCIMode:
"""Tests for CI mode functionality."""
def test_ci_mode_clean_output(self):
"""Test that CI mode produces cleaner output."""
with tempfile.TemporaryDirectory() as tmpdir:
schema_path = os.path.join(tmpdir, "schema.json")
env_path = os.path.join(tmpdir, ".env")
schema_data = {
"version": "1.0",
"envVars": [
{"name": "DATABASE_URL", "type": "str", "required": True},
]
}
with open(schema_path, "w") as f:
json.dump(schema_data, f)
with open(env_path, "w") as f:
f.write("DATABASE_URL=postgres://localhost/mydb\n")
runner = CliRunner()
result = runner.invoke(cli, ["validate", schema_path, "--file", env_path, "--no-env", "--ci"])
assert result.exit_code == 0
assert "" not in result.output
assert "" not in result.output
def test_ci_mode_json_output(self):
"""Test CI mode with JSON output."""
with tempfile.TemporaryDirectory() as tmpdir:
schema_path = os.path.join(tmpdir, "schema.json")
env_path = os.path.join(tmpdir, ".env")
schema_data = {
"version": "1.0",
"envVars": [
{"name": "DATABASE_URL", "type": "str", "required": True},
]
}
with open(schema_path, "w") as f:
json.dump(schema_data, f)
with open(env_path, "w") as f:
f.write("DATABASE_URL=postgres://localhost/mydb\n")
runner = CliRunner()
result = runner.invoke(cli, ["validate", schema_path, "--file", env_path, "--no-env", "--ci", "--format", "json"])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["is_valid"] is True

View File

View File

@@ -0,0 +1,188 @@
"""Unit tests for the validation engine."""
import pytest
from envschema.schema import Schema, EnvVar, EnvVarType
from envschema.core import ValidationEngine, ValidationResult
class TestValidationResult:
"""Tests for ValidationResult."""
def test_valid_result(self):
result = ValidationResult(is_valid=True)
assert result.is_valid is True
assert result.missing_required == []
assert result.type_errors == []
assert result.pattern_errors == []
assert result.warnings == []
def test_result_to_dict(self):
result = ValidationResult(is_valid=True)
d = result.to_dict()
assert d["is_valid"] is True
assert d["missing_required"] == []
class TestValidationEngine:
"""Tests for ValidationEngine."""
def test_validate_empty_env(self):
schema = Schema(envvars=[])
engine = ValidationEngine(schema)
result = engine.validate({})
assert result.is_valid is True
def test_validate_missing_required(self):
schema = Schema(
envvars=[
EnvVar(name="REQUIRED_VAR", required=True),
]
)
engine = ValidationEngine(schema)
result = engine.validate({})
assert result.is_valid is False
assert "REQUIRED_VAR" in result.missing_required
def test_validate_present_required(self):
schema = Schema(
envvars=[
EnvVar(name="REQUIRED_VAR", required=True),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"REQUIRED_VAR": "value"})
assert result.is_valid is True
def test_validate_optional_missing(self):
schema = Schema(
envvars=[
EnvVar(name="OPTIONAL_VAR", required=False),
]
)
engine = ValidationEngine(schema)
result = engine.validate({})
assert result.is_valid is True
def test_validate_with_default(self):
schema = Schema(
envvars=[
EnvVar(name="VAR_WITH_DEFAULT", required=False, default="default_value"),
]
)
engine = ValidationEngine(schema)
result = engine.validate({})
assert result.is_valid is True
def test_validate_string_type(self):
schema = Schema(
envvars=[
EnvVar(name="STRING_VAR", type=EnvVarType.STRING),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"STRING_VAR": "any value"})
assert result.is_valid is True
def test_validate_integer_type_valid(self):
schema = Schema(
envvars=[
EnvVar(name="INT_VAR", type=EnvVarType.INTEGER),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"INT_VAR": "42"})
assert result.is_valid is True
def test_validate_integer_type_invalid(self):
schema = Schema(
envvars=[
EnvVar(name="INT_VAR", type=EnvVarType.INTEGER),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"INT_VAR": "not_a_number"})
assert result.is_valid is False
assert len(result.type_errors) == 1
assert result.type_errors[0].var_name == "INT_VAR"
def test_validate_boolean_type_valid(self):
schema = Schema(
envvars=[
EnvVar(name="BOOL_VAR", type=EnvVarType.BOOLEAN),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"BOOL_VAR": "true"})
assert result.is_valid is True
def test_validate_boolean_type_invalid(self):
schema = Schema(
envvars=[
EnvVar(name="BOOL_VAR", type=EnvVarType.BOOLEAN),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"BOOL_VAR": "maybe"})
assert result.is_valid is False
def test_validate_list_type_valid(self):
schema = Schema(
envvars=[
EnvVar(name="LIST_VAR", type=EnvVarType.LIST),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"LIST_VAR": "a,b,c"})
assert result.is_valid is True
def test_validate_list_type_invalid(self):
schema = Schema(
envvars=[
EnvVar(name="LIST_VAR", type=EnvVarType.LIST),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"LIST_VAR": "single_value"})
assert result.is_valid is False
def test_validate_pattern_match(self):
schema = Schema(
envvars=[
EnvVar(name="PATTERN_VAR", type=EnvVarType.STRING, pattern=r"^[A-Z]+$"),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"PATTERN_VAR": "VALID"})
assert result.is_valid is True
def test_validate_pattern_no_match(self):
schema = Schema(
envvars=[
EnvVar(name="PATTERN_VAR", type=EnvVarType.STRING, pattern=r"^[A-Z]+$"),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"PATTERN_VAR": "invalid"})
assert result.is_valid is False
def test_validate_extra_var_warning(self):
schema = Schema(
envvars=[
EnvVar(name="KNOWN_VAR", type=EnvVarType.STRING),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"KNOWN_VAR": "value", "UNKNOWN_VAR": "other"})
assert result.is_valid is True
assert "Unknown environment variable: UNKNOWN_VAR" in result.warnings
def test_validate_case_insensitive(self):
schema = Schema(
envvars=[
EnvVar(name="TEST_VAR", required=True),
]
)
engine = ValidationEngine(schema)
result = engine.validate({"test_var": "value"})
assert result.is_valid is True

View File

@@ -0,0 +1,105 @@
"""Unit tests for the .env.example generator."""
import pytest
from envschema.schema import Schema, EnvVar, EnvVarType
from envschema.generator import generate_env_example, generate_env_example_to_file
class TestGenerateEnvExample:
"""Tests for generate_env_example function."""
def test_empty_schema(self):
schema = Schema()
result = generate_env_example(schema)
assert "# Environment Variables Schema" in result
def test_basic_variable(self):
schema = Schema(
envvars=[
EnvVar(name="TEST_VAR", type=EnvVarType.STRING),
]
)
result = generate_env_example(schema)
assert "TEST_VAR=" in result
def test_required_variable(self):
schema = Schema(
envvars=[
EnvVar(name="REQUIRED_VAR", required=True),
]
)
result = generate_env_example(schema)
assert "# REQUIRED" in result
assert "REQUIRED_VAR=" in result
def test_variable_with_default(self):
schema = Schema(
envvars=[
EnvVar(name="VAR_WITH_DEFAULT", default="default_value"),
]
)
result = generate_env_example(schema)
assert "VAR_WITH_DEFAULT=default_value" in result
def test_variable_with_description(self):
schema = Schema(
envvars=[
EnvVar(
name="DESCRIBED_VAR",
description="This is a description",
),
]
)
result = generate_env_example(schema)
assert "# This is a description" in result
def test_variable_with_type(self):
schema = Schema(
envvars=[
EnvVar(name="INT_VAR", type=EnvVarType.INTEGER),
]
)
result = generate_env_example(schema)
assert "INT_VAR=" in result
def test_no_descriptions(self):
schema = Schema(
envvars=[
EnvVar(
name="VAR",
description="Some description",
),
]
)
result = generate_env_example(schema, include_descriptions=False)
assert "Some description" not in result
def test_multiple_variables(self):
schema = Schema(
envvars=[
EnvVar(name="VAR1", required=True, description="First var"),
EnvVar(name="VAR2", default="value"),
EnvVar(name="VAR3", type=EnvVarType.INTEGER),
]
)
result = generate_env_example(schema)
assert "VAR1=" in result
assert "VAR2=value" in result
assert "VAR3=" in result
class TestGenerateEnvExampleToFile:
"""Tests for generate_env_example_to_file function."""
def test_write_to_file(self, tmp_path):
schema = Schema(
envvars=[
EnvVar(name="TEST_VAR"),
]
)
output_path = tmp_path / ".env.example"
generate_env_example_to_file(schema, str(output_path))
content = output_path.read_text()
assert "TEST_VAR=" in content

View File

@@ -0,0 +1,174 @@
"""Unit tests for schema parsing."""
import json
import tempfile
from pathlib import Path
import pytest
from envschema.schema import (
Schema,
EnvVar,
EnvVarType,
load_schema_from_file,
load_json_schema,
load_yaml_schema,
)
class TestEnvVar:
"""Tests for EnvVar model."""
def test_env_var_creation(self):
var = EnvVar(name="TEST_VAR", type=EnvVarType.STRING)
assert var.name == "TEST_VAR"
assert var.type == EnvVarType.STRING
assert var.required is False
assert var.default is None
def test_env_var_with_all_fields(self):
var = EnvVar(
name="DATABASE_URL",
type=EnvVarType.STRING,
required=True,
default="postgres://localhost",
description="Database connection string",
pattern=r"^postgres://.*",
)
assert var.required is True
assert var.default == "postgres://localhost"
assert var.description == "Database connection string"
assert var.pattern == r"^postgres://.*"
def test_env_var_name_uppercase(self):
var = EnvVar(name="test_var")
assert var.name == "TEST_VAR"
def test_env_var_invalid_name(self):
with pytest.raises(ValueError):
EnvVar(name="invalid name with spaces")
class TestSchema:
"""Tests for Schema model."""
def test_schema_creation(self):
schema = Schema()
assert schema.version == "1.0"
assert schema.envvars == []
def test_schema_with_vars(self):
schema = Schema(
envvars=[
EnvVar(name="VAR1", type=EnvVarType.STRING),
EnvVar(name="VAR2", type=EnvVarType.INTEGER, required=True),
]
)
assert len(schema.envvars) == 2
def test_get_var(self):
schema = Schema(
envvars=[
EnvVar(name="DATABASE_URL", type=EnvVarType.STRING),
]
)
var = schema.get_var("DATABASE_URL")
assert var is not None
assert var.name == "DATABASE_URL"
def test_get_var_case_insensitive(self):
schema = Schema(
envvars=[
EnvVar(name="DATABASE_URL", type=EnvVarType.STRING),
]
)
var = schema.get_var("database_url")
assert var is not None
def test_get_var_not_found(self):
schema = Schema()
var = schema.get_var("NONEXISTENT")
assert var is None
def test_get_required_vars(self):
schema = Schema(
envvars=[
EnvVar(name="VAR1", required=True),
EnvVar(name="VAR2", required=False),
EnvVar(name="VAR3", required=True),
]
)
required = schema.get_required_vars()
assert len(required) == 2
assert {v.name for v in required} == {"VAR1", "VAR3"}
class TestLoadJsonSchema:
"""Tests for JSON schema loading."""
def test_load_valid_json_schema(self):
json_content = json.dumps({
"version": "1.0",
"envVars": [
{"name": "TEST_VAR", "type": "str"}
]
})
schema = load_json_schema(json_content)
assert schema.version == "1.0"
assert len(schema.envvars) == 1
def test_load_invalid_json(self):
with pytest.raises(ValueError, match="Invalid JSON"):
load_json_schema("not valid json")
def test_load_invalid_schema_structure(self):
with pytest.raises((ValueError, Exception), match="Invalid schema"):
load_json_schema('{"version": "1.0", "envVars": [{"name": "VAR", "type": "invalid_type"}]}')
class TestLoadYamlSchema:
"""Tests for YAML schema loading."""
def test_load_valid_yaml_schema(self):
yaml_content = """
version: "1.0"
envVars:
- name: TEST_VAR
type: str
"""
schema = load_yaml_schema(yaml_content)
assert schema.version == "1.0"
assert len(schema.envvars) == 1
def test_load_invalid_yaml(self):
with pytest.raises(ValueError, match="Invalid YAML"):
load_yaml_schema("invalid: yaml: content:")
class TestLoadSchemaFromFile:
"""Tests for file-based schema loading."""
def test_load_json_file(self, tmp_path):
schema_file = tmp_path / "schema.json"
schema_file.write_text(json.dumps({
"version": "1.0",
"envVars": [{"name": "TEST", "type": "str"}]
}))
schema = load_schema_from_file(str(schema_file))
assert schema.version == "1.0"
def test_load_yaml_file(self, tmp_path):
schema_file = tmp_path / "schema.yaml"
schema_file.write_text('version: "1.0"\nenvVars: []')
schema = load_schema_from_file(str(schema_file))
assert schema.version == "1.0"
def test_file_not_found(self):
with pytest.raises(FileNotFoundError):
load_schema_from_file("/nonexistent/path/schema.json")
def test_unsupported_format(self, tmp_path):
schema_file = tmp_path / "schema.txt"
schema_file.write_text("some content")
with pytest.raises(ValueError, match="Unsupported schema format"):
load_schema_from_file(str(schema_file))

View File

@@ -0,0 +1,176 @@
"""Unit tests for type validators."""
import pytest
from envschema.schema import EnvVarType
from envschema.validators import (
StringValidator,
IntegerValidator,
BooleanValidator,
ListValidator,
PatternValidator,
validate_value,
)
class TestStringValidator:
"""Tests for StringValidator."""
def test_valid_string(self):
is_valid, error = StringValidator.validate("any value")
assert is_valid is True
assert error is None
def test_empty_string(self):
is_valid, error = StringValidator.validate("")
assert is_valid is True
assert error is None
def test_none_value(self):
is_valid, error = StringValidator.validate(None)
assert is_valid is True
assert error is None
class TestIntegerValidator:
"""Tests for IntegerValidator."""
def test_valid_integer(self):
is_valid, error = IntegerValidator.validate("42")
assert is_valid is True
assert error is None
def test_valid_negative_integer(self):
is_valid, error = IntegerValidator.validate("-10")
assert is_valid is True
assert error is None
def test_valid_zero(self):
is_valid, error = IntegerValidator.validate("0")
assert is_valid is True
assert error is None
def test_invalid_float(self):
is_valid, error = IntegerValidator.validate("3.14")
assert is_valid is False
assert error is not None
def test_invalid_string(self):
is_valid, error = IntegerValidator.validate("abc")
assert is_valid is False
assert error is not None
def test_none_value(self):
is_valid, error = IntegerValidator.validate(None)
assert is_valid is True
assert error is None
class TestBooleanValidator:
"""Tests for BooleanValidator."""
@pytest.mark.parametrize("value", ["true", "True", "TRUE", "1", "yes", "Yes", "YES", "on", "ON"])
def test_valid_true_values(self, value):
is_valid, error = BooleanValidator.validate(value)
assert is_valid is True
assert error is None
@pytest.mark.parametrize("value", ["false", "False", "FALSE", "0", "no", "No", "NO", "off", "OFF"])
def test_valid_false_values(self, value):
is_valid, error = BooleanValidator.validate(value)
assert is_valid is True
assert error is None
@pytest.mark.parametrize("value", ["maybe", "2", "truee", "yess"])
def test_invalid_boolean_values(self, value):
is_valid, error = BooleanValidator.validate(value)
assert is_valid is False
assert error is not None
def test_none_value(self):
is_valid, error = BooleanValidator.validate(None)
assert is_valid is True
assert error is None
class TestListValidator:
"""Tests for ListValidator."""
def test_valid_list(self):
is_valid, error = ListValidator.validate("item1,item2,item3")
assert is_valid is True
assert error is None
def test_single_item_list(self):
is_valid, error = ListValidator.validate("single")
assert is_valid is False
assert error is not None
def test_empty_string(self):
is_valid, error = ListValidator.validate("")
assert is_valid is False
def test_none_value(self):
is_valid, error = ListValidator.validate(None)
assert is_valid is True
assert error is None
def test_parse_list(self):
result = ListValidator.parse("item1, item2 , item3")
assert result == ["item1", "item2", "item3"]
def test_parse_list_with_empty_items(self):
result = ListValidator.parse("item1,,item2")
assert result == ["item1", "item2"]
class TestPatternValidator:
"""Tests for PatternValidator."""
def test_valid_pattern_match(self):
is_valid, error = PatternValidator.validate("ABC123", r"^[A-Z]+[0-9]+$")
assert is_valid is True
assert error is None
def test_invalid_pattern_match(self):
is_valid, error = PatternValidator.validate("abc123", r"^[A-Z]+[0-9]+$")
assert is_valid is False
assert error is not None
def test_invalid_regex_pattern(self):
is_valid, error = PatternValidator.validate("test", r"[invalid")
assert is_valid is False
assert error is not None
def test_none_value(self):
is_valid, error = PatternValidator.validate(None, r"^[A-Z]+$")
assert is_valid is True
assert error is None
class TestValidateValue:
"""Tests for the main validate_value function."""
def test_validate_string(self):
is_valid, error = validate_value("test", EnvVarType.STRING)
assert is_valid is True
def test_validate_integer(self):
is_valid, error = validate_value("42", EnvVarType.INTEGER)
assert is_valid is True
def test_validate_boolean(self):
is_valid, error = validate_value("true", EnvVarType.BOOLEAN)
assert is_valid is True
def test_validate_list(self):
is_valid, error = validate_value("a,b,c", EnvVarType.LIST)
assert is_valid is True
def test_validate_with_pattern(self):
is_valid, error = validate_value("ABC123", EnvVarType.STRING, r"^[A-Z]+[0-9]+$")
assert is_valid is True
def test_validate_with_invalid_pattern(self):
is_valid, error = validate_value("abc123", EnvVarType.STRING, r"^[A-Z]+[0-9]+$")
assert is_valid is False