Re-upload: CI infrastructure issue resolved, all tests verified passing
This commit is contained in:
45
envschema_repo/.env.schema.json
Normal file
45
envschema_repo/.env.schema.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
38
envschema_repo/.gitea/workflows/ci.yml
Normal file
38
envschema_repo/.gitea/workflows/ci.yml
Normal 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
79
envschema_repo/.gitignore
vendored
Normal 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
21
envschema_repo/LICENSE
Normal 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
201
envschema_repo/README.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# EnvSchema
|
||||
|
||||
[](https://7000pct.gitea.bloupla.net/7000pctAUTO/envschema/actions)
|
||||
[](https://pypi.org/project/envschema/)
|
||||
[](https://pypi.org/project/envschema/)
|
||||
[](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.
|
||||
17
envschema_repo/envschema/__init__.py
Normal file
17
envschema_repo/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",
|
||||
]
|
||||
152
envschema_repo/envschema/cli.py
Normal file
152
envschema_repo/envschema/cli.py
Normal 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()
|
||||
162
envschema_repo/envschema/core.py
Normal file
162
envschema_repo/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, 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)
|
||||
133
envschema_repo/envschema/formatters.py
Normal file
133
envschema_repo/envschema/formatters.py
Normal 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)
|
||||
57
envschema_repo/envschema/generator.py
Normal file
57
envschema_repo/envschema/generator.py
Normal 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)
|
||||
91
envschema_repo/envschema/loader.py
Normal file
91
envschema_repo/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_repo/envschema/schema.py
Normal file
110
envschema_repo/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_repo/envschema/validators.py
Normal file
153
envschema_repo/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(
|
||||
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
|
||||
49
envschema_repo/pyproject.toml
Normal file
49
envschema_repo/pyproject.toml
Normal 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
37
envschema_repo/setup.py
Normal 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",
|
||||
],
|
||||
)
|
||||
0
envschema_repo/tests/__init__.py
Normal file
0
envschema_repo/tests/__init__.py
Normal file
73
envschema_repo/tests/conftest.py
Normal file
73
envschema_repo/tests/conftest.py
Normal 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)
|
||||
0
envschema_repo/tests/integration/__init__.py
Normal file
0
envschema_repo/tests/integration/__init__.py
Normal file
120
envschema_repo/tests/integration/test_cli.py
Normal file
120
envschema_repo/tests/integration/test_cli.py
Normal 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)
|
||||
183
envschema_repo/tests/integration/test_full_flow.py
Normal file
183
envschema_repo/tests/integration/test_full_flow.py
Normal 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
|
||||
0
envschema_repo/tests/unit/__init__.py
Normal file
0
envschema_repo/tests/unit/__init__.py
Normal file
188
envschema_repo/tests/unit/test_core.py
Normal file
188
envschema_repo/tests/unit/test_core.py
Normal 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
|
||||
105
envschema_repo/tests/unit/test_generator.py
Normal file
105
envschema_repo/tests/unit/test_generator.py
Normal 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
|
||||
174
envschema_repo/tests/unit/test_schema.py
Normal file
174
envschema_repo/tests/unit/test_schema.py
Normal 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))
|
||||
176
envschema_repo/tests/unit/test_validators.py
Normal file
176
envschema_repo/tests/unit/test_validators.py
Normal 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
|
||||
Reference in New Issue
Block a user