environment-guard 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
env_guard/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ env-guard: Zero-config environment variable validation.
3
+
4
+ Infer types from .env.example, validate at runtime, fail fast with beautiful errors.
5
+ """
6
+
7
+ from .validator import (
8
+ validate,
9
+ validate_env,
10
+ EnvValidationError,
11
+ EnvVar,
12
+ InferredType,
13
+ ValidationResult,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = [
18
+ "validate",
19
+ "validate_env",
20
+ "EnvValidationError",
21
+ "EnvVar",
22
+ "InferredType",
23
+ "ValidationResult",
24
+ "__version__",
25
+ ]
26
+
27
+
28
+ def main() -> None:
29
+ """CLI entry point for env-guard."""
30
+ import sys
31
+ import argparse
32
+
33
+ parser = argparse.ArgumentParser(
34
+ prog="env-guard",
35
+ description="Validate environment variables against .env.example",
36
+ )
37
+ parser.add_argument(
38
+ "-e", "--example",
39
+ default=".env.example",
40
+ help="Path to .env.example file (default: .env.example)",
41
+ )
42
+ parser.add_argument(
43
+ "-q", "--quiet",
44
+ action="store_true",
45
+ help="Only output errors, no success messages",
46
+ )
47
+ parser.add_argument(
48
+ "--no-exit",
49
+ action="store_true",
50
+ help="Don't exit with code 1 on validation failure",
51
+ )
52
+ parser.add_argument(
53
+ "-v", "--version",
54
+ action="version",
55
+ version=f"%(prog)s {__version__}",
56
+ )
57
+
58
+ args = parser.parse_args()
59
+
60
+ try:
61
+ result = validate_env(example_path=args.example, exit_on_error=False)
62
+
63
+ if result.is_valid:
64
+ if not args.quiet:
65
+ print(f"\n All {len(result.variables)} environment variables validated successfully!")
66
+ sys.exit(0)
67
+ else:
68
+ if not args.no_exit:
69
+ sys.exit(1)
70
+ except FileNotFoundError as e:
71
+ print(f"Error: {e}", file=sys.stderr)
72
+ sys.exit(1)
env_guard/validator.py ADDED
@@ -0,0 +1,482 @@
1
+ """
2
+ Core validation logic for env-guard.
3
+
4
+ This module provides environment variable validation by:
5
+ 1. Parsing .env.example to infer expected variables and their types
6
+ 2. Checking os.environ for presence and type conformance
7
+ 3. Reporting all errors with colorized, grouped output
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import re
14
+ import sys
15
+ from dataclasses import dataclass, field
16
+ from enum import Enum
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from dotenv import dotenv_values
21
+
22
+
23
+ class InferredType(Enum):
24
+ """Types that can be inferred from .env.example values."""
25
+
26
+ STRING = "string"
27
+ INTEGER = "integer"
28
+ FLOAT = "float"
29
+ BOOLEAN = "boolean"
30
+
31
+ def __str__(self) -> str:
32
+ return self.value
33
+
34
+
35
+ @dataclass
36
+ class EnvVar:
37
+ """Represents an environment variable specification."""
38
+
39
+ name: str
40
+ example_value: str
41
+ inferred_type: InferredType
42
+ optional: bool = False
43
+ comment: str | None = None
44
+
45
+ def validate_value(self, value: str) -> tuple[bool, str | None]:
46
+ """
47
+ Validate that a value conforms to this variable's inferred type.
48
+
49
+ Returns:
50
+ Tuple of (is_valid, error_message)
51
+ """
52
+ if self.inferred_type == InferredType.INTEGER:
53
+ try:
54
+ int(value)
55
+ return True, None
56
+ except ValueError:
57
+ return False, f"expected integer, got '{value}'"
58
+
59
+ elif self.inferred_type == InferredType.FLOAT:
60
+ try:
61
+ float(value)
62
+ return True, None
63
+ except ValueError:
64
+ return False, f"expected float, got '{value}'"
65
+
66
+ elif self.inferred_type == InferredType.BOOLEAN:
67
+ if value.lower() in ("true", "false", "1", "0", "yes", "no"):
68
+ return True, None
69
+ return False, f"expected boolean (true/false/1/0/yes/no), got '{value}'"
70
+
71
+ # STRING type accepts anything
72
+ return True, None
73
+
74
+
75
+ @dataclass
76
+ class ValidationError:
77
+ """Represents a single validation error."""
78
+
79
+ var_name: str
80
+ error_type: str # "missing" or "type_mismatch"
81
+ message: str
82
+ expected_type: InferredType | None = None
83
+ actual_value: str | None = None
84
+
85
+
86
+ @dataclass
87
+ class ValidationResult:
88
+ """Result of environment validation."""
89
+
90
+ is_valid: bool
91
+ variables: list[EnvVar]
92
+ errors: list[ValidationError] = field(default_factory=list)
93
+ validated_values: dict[str, Any] = field(default_factory=dict)
94
+
95
+
96
+ class EnvValidationError(Exception):
97
+ """Raised when environment validation fails."""
98
+
99
+ def __init__(self, result: ValidationResult):
100
+ self.result = result
101
+ super().__init__(f"Environment validation failed with {len(result.errors)} error(s)")
102
+
103
+
104
+ def infer_type(value: str) -> InferredType:
105
+ """
106
+ Infer the type of an environment variable from its example value.
107
+
108
+ Rules:
109
+ - Integers: digits only, optionally with leading minus
110
+ - Floats: digits with decimal point
111
+ - Booleans: true/false/yes/no (case insensitive)
112
+ - Everything else: string
113
+ """
114
+ if not value:
115
+ return InferredType.STRING
116
+
117
+ # Check for boolean
118
+ if value.lower() in ("true", "false", "yes", "no"):
119
+ return InferredType.BOOLEAN
120
+
121
+ # Check for integer (including negative)
122
+ if re.match(r"^-?\d+$", value):
123
+ return InferredType.INTEGER
124
+
125
+ # Check for float (including negative, must have decimal point)
126
+ if re.match(r"^-?\d+\.\d+$", value):
127
+ return InferredType.FLOAT
128
+
129
+ return InferredType.STRING
130
+
131
+
132
+ def is_optional_marker(comment: str | None) -> bool:
133
+ """Check if a comment indicates the variable is optional."""
134
+ if not comment:
135
+ return False
136
+ comment_lower = comment.lower()
137
+ return any(marker in comment_lower for marker in ["optional", "?", "not required"])
138
+
139
+
140
+ def parse_env_example(path: str | Path) -> list[EnvVar]:
141
+ """
142
+ Parse a .env.example file and return a list of EnvVar specifications.
143
+
144
+ Handles:
145
+ - Standard KEY=value pairs
146
+ - Comments (# ...) for optional markers
147
+ - Empty values
148
+ """
149
+ path = Path(path)
150
+ if not path.exists():
151
+ raise FileNotFoundError(f"Example file not found: {path}")
152
+
153
+ # Use dotenv to parse key-value pairs
154
+ values = dotenv_values(path)
155
+
156
+ # Also read raw content to extract comments
157
+ content = path.read_text(encoding="utf-8")
158
+ lines = content.splitlines()
159
+
160
+ # Build a map of variable names to their inline comments
161
+ var_comments: dict[str, str] = {}
162
+ for line in lines:
163
+ line = line.strip()
164
+ if not line or line.startswith("#"):
165
+ continue
166
+
167
+ # Check for inline comment
168
+ if "#" in line:
169
+ # Split on first = to get the key
170
+ if "=" in line:
171
+ key_part = line.split("=")[0].strip()
172
+ comment_part = line.split("#", 1)[1].strip() if "#" in line else ""
173
+ var_comments[key_part] = comment_part
174
+
175
+ env_vars = []
176
+ for name, value in values.items():
177
+ value = value or ""
178
+ inferred = infer_type(value)
179
+ comment = var_comments.get(name)
180
+ optional = is_optional_marker(comment)
181
+
182
+ env_vars.append(
183
+ EnvVar(
184
+ name=name,
185
+ example_value=value,
186
+ inferred_type=inferred,
187
+ optional=optional,
188
+ comment=comment,
189
+ )
190
+ )
191
+
192
+ return env_vars
193
+
194
+
195
+ def validate_environment(
196
+ env_vars: list[EnvVar],
197
+ environ: dict[str, str] | None = None,
198
+ ) -> ValidationResult:
199
+ """
200
+ Validate environment variables against specifications.
201
+
202
+ Args:
203
+ env_vars: List of expected environment variables
204
+ environ: Environment dict to validate (defaults to os.environ)
205
+
206
+ Returns:
207
+ ValidationResult with status, errors, and validated values
208
+ """
209
+ if environ is None:
210
+ environ = dict(os.environ)
211
+
212
+ errors: list[ValidationError] = []
213
+ validated_values: dict[str, Any] = {}
214
+
215
+ for var in env_vars:
216
+ value = environ.get(var.name)
217
+
218
+ # Check if missing
219
+ if value is None:
220
+ if not var.optional:
221
+ errors.append(
222
+ ValidationError(
223
+ var_name=var.name,
224
+ error_type="missing",
225
+ message=f"Required variable '{var.name}' is not set",
226
+ expected_type=var.inferred_type,
227
+ )
228
+ )
229
+ continue
230
+
231
+ # Check type
232
+ is_valid, error_msg = var.validate_value(value)
233
+ if not is_valid:
234
+ errors.append(
235
+ ValidationError(
236
+ var_name=var.name,
237
+ error_type="type_mismatch",
238
+ message=f"Variable '{var.name}': {error_msg}",
239
+ expected_type=var.inferred_type,
240
+ actual_value=value,
241
+ )
242
+ )
243
+ else:
244
+ # Store the coerced value
245
+ validated_values[var.name] = _coerce_value(value, var.inferred_type)
246
+
247
+ return ValidationResult(
248
+ is_valid=len(errors) == 0,
249
+ variables=env_vars,
250
+ errors=errors,
251
+ validated_values=validated_values,
252
+ )
253
+
254
+
255
+ def _coerce_value(value: str, inferred_type: InferredType) -> Any:
256
+ """Convert a string value to its inferred type."""
257
+ if inferred_type == InferredType.INTEGER:
258
+ return int(value)
259
+ elif inferred_type == InferredType.FLOAT:
260
+ return float(value)
261
+ elif inferred_type == InferredType.BOOLEAN:
262
+ return value.lower() in ("true", "1", "yes")
263
+ return value
264
+
265
+
266
+ # =============================================================================
267
+ # Output Formatting (with optional rich support)
268
+ # =============================================================================
269
+
270
+ try:
271
+ from rich.console import Console
272
+ from rich.table import Table
273
+ from rich.panel import Panel
274
+ from rich.text import Text
275
+
276
+ RICH_AVAILABLE = True
277
+ except ImportError:
278
+ RICH_AVAILABLE = False
279
+
280
+
281
+ def _format_output_rich(result: ValidationResult) -> None:
282
+ """Format validation output using rich library."""
283
+ console = Console(stderr=True)
284
+
285
+ if result.is_valid:
286
+ # Success output
287
+ console.print()
288
+ text = Text()
289
+ text.append(" ", style="green")
290
+ text.append(f"All {len(result.variables)} environment variables validated successfully!", style="green bold")
291
+ console.print(Panel(text, border_style="green", title="env-guard"))
292
+ console.print()
293
+ return
294
+
295
+ # Error output
296
+ console.print()
297
+ console.print(Panel(
298
+ f"[red bold]Environment validation failed with {len(result.errors)} error(s)[/]",
299
+ border_style="red",
300
+ title="env-guard",
301
+ ))
302
+ console.print()
303
+
304
+ # Group errors by type
305
+ missing = [e for e in result.errors if e.error_type == "missing"]
306
+ type_errors = [e for e in result.errors if e.error_type == "type_mismatch"]
307
+
308
+ if missing:
309
+ table = Table(title="Missing Variables", border_style="red", title_style="red bold")
310
+ table.add_column("Variable", style="cyan")
311
+ table.add_column("Expected Type", style="yellow")
312
+
313
+ for error in missing:
314
+ table.add_row(error.var_name, str(error.expected_type))
315
+
316
+ console.print(table)
317
+ console.print()
318
+
319
+ if type_errors:
320
+ table = Table(title="Type Mismatches", border_style="red", title_style="red bold")
321
+ table.add_column("Variable", style="cyan")
322
+ table.add_column("Expected", style="yellow")
323
+ table.add_column("Got", style="red")
324
+
325
+ for error in type_errors:
326
+ table.add_row(
327
+ error.var_name,
328
+ str(error.expected_type),
329
+ repr(error.actual_value),
330
+ )
331
+
332
+ console.print(table)
333
+ console.print()
334
+
335
+ # Hint
336
+ console.print("[dim]Hint: Set missing variables or fix type mismatches in your environment.[/]")
337
+ console.print()
338
+
339
+
340
+ def _format_output_plain(result: ValidationResult) -> None:
341
+ """Format validation output using ANSI codes (no rich dependency)."""
342
+ # ANSI color codes
343
+ RED = "\033[91m"
344
+ GREEN = "\033[92m"
345
+ YELLOW = "\033[93m"
346
+ CYAN = "\033[96m"
347
+ BOLD = "\033[1m"
348
+ DIM = "\033[2m"
349
+ RESET = "\033[0m"
350
+
351
+ if result.is_valid:
352
+ print(file=sys.stderr)
353
+ print(f"{GREEN}{BOLD} All {len(result.variables)} environment variables validated successfully!{RESET}", file=sys.stderr)
354
+ print(file=sys.stderr)
355
+ return
356
+
357
+ # Error output
358
+ print(file=sys.stderr)
359
+ print(f"{RED}{BOLD}=== env-guard: Validation Failed ==={RESET}", file=sys.stderr)
360
+ print(f"{RED}Found {len(result.errors)} error(s){RESET}", file=sys.stderr)
361
+ print(file=sys.stderr)
362
+
363
+ # Group errors by type
364
+ missing = [e for e in result.errors if e.error_type == "missing"]
365
+ type_errors = [e for e in result.errors if e.error_type == "type_mismatch"]
366
+
367
+ if missing:
368
+ print(f"{RED}{BOLD}Missing Variables:{RESET}", file=sys.stderr)
369
+ for error in missing:
370
+ print(f" {CYAN}{error.var_name}{RESET} - expected {YELLOW}{error.expected_type}{RESET}", file=sys.stderr)
371
+ print(file=sys.stderr)
372
+
373
+ if type_errors:
374
+ print(f"{RED}{BOLD}Type Mismatches:{RESET}", file=sys.stderr)
375
+ for error in type_errors:
376
+ print(
377
+ f" {CYAN}{error.var_name}{RESET}: expected {YELLOW}{error.expected_type}{RESET}, "
378
+ f"got {RED}{repr(error.actual_value)}{RESET}",
379
+ file=sys.stderr
380
+ )
381
+ print(file=sys.stderr)
382
+
383
+ print(f"{DIM}Hint: Set missing variables or fix type mismatches in your environment.{RESET}", file=sys.stderr)
384
+ print(file=sys.stderr)
385
+
386
+
387
+ def print_validation_result(result: ValidationResult, use_rich: bool | None = None) -> None:
388
+ """
389
+ Print validation result with colorized output.
390
+
391
+ Args:
392
+ result: The validation result to display
393
+ use_rich: Force rich (True) or plain (False) output. None = auto-detect.
394
+ """
395
+ if use_rich is None:
396
+ use_rich = RICH_AVAILABLE
397
+
398
+ if use_rich and RICH_AVAILABLE:
399
+ _format_output_rich(result)
400
+ else:
401
+ _format_output_plain(result)
402
+
403
+
404
+ # =============================================================================
405
+ # Public API
406
+ # =============================================================================
407
+
408
+ def validate_env(
409
+ example_path: str | Path = ".env.example",
410
+ exit_on_error: bool = True,
411
+ use_rich: bool | None = None,
412
+ quiet: bool = False,
413
+ ) -> ValidationResult:
414
+ """
415
+ Validate environment variables against a .env.example file.
416
+
417
+ This is the main entry point for most users.
418
+
419
+ Args:
420
+ example_path: Path to the .env.example file
421
+ exit_on_error: If True, calls sys.exit(1) on validation failure
422
+ use_rich: Force rich (True) or plain (False) output. None = auto-detect.
423
+ quiet: If True, suppress all output (useful for programmatic use)
424
+
425
+ Returns:
426
+ ValidationResult object
427
+
428
+ Raises:
429
+ FileNotFoundError: If the example file doesn't exist
430
+ SystemExit: If validation fails and exit_on_error is True
431
+
432
+ Example:
433
+ >>> from env_guard import validate_env
434
+ >>> result = validate_env() # Uses .env.example by default
435
+ """
436
+ env_vars = parse_env_example(example_path)
437
+ result = validate_environment(env_vars)
438
+
439
+ if not quiet:
440
+ print_validation_result(result, use_rich=use_rich)
441
+
442
+ if not result.is_valid and exit_on_error:
443
+ sys.exit(1)
444
+
445
+ return result
446
+
447
+
448
+ def validate(
449
+ example_path: str | Path = ".env.example",
450
+ environ: dict[str, str] | None = None,
451
+ ) -> dict[str, Any]:
452
+ """
453
+ Validate and return typed environment values.
454
+
455
+ A more Pythonic alternative that returns the validated values directly
456
+ or raises an exception on failure.
457
+
458
+ Args:
459
+ example_path: Path to the .env.example file
460
+ environ: Environment dict to validate (defaults to os.environ)
461
+
462
+ Returns:
463
+ Dictionary of variable names to their typed values
464
+
465
+ Raises:
466
+ FileNotFoundError: If the example file doesn't exist
467
+ EnvValidationError: If validation fails
468
+
469
+ Example:
470
+ >>> from env_guard import validate
471
+ >>> env = validate()
472
+ >>> print(env["PORT"]) # Already an int!
473
+ 8000
474
+ """
475
+ env_vars = parse_env_example(example_path)
476
+ result = validate_environment(env_vars, environ)
477
+
478
+ if not result.is_valid:
479
+ print_validation_result(result)
480
+ raise EnvValidationError(result)
481
+
482
+ return result.validated_values
@@ -0,0 +1,352 @@
1
+ Metadata-Version: 2.4
2
+ Name: environment-guard
3
+ Version: 0.1.0
4
+ Summary: Zero-config environment variable validation - infer types from .env.example, validate at runtime
5
+ Project-URL: Homepage, https://github.com/ljo3/env-guard
6
+ Project-URL: Documentation, https://github.com/ljo3/env-guard#readme
7
+ Project-URL: Repository, https://github.com/ljo3/env-guard
8
+ Project-URL: Issues, https://github.com/ljo3/env-guard/issues
9
+ Author-email: Your Name <your.email@example.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: config,configuration,dotenv,env,environment,type-checking,validation
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Topic :: System :: Systems Administration
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Requires-Dist: python-dotenv>=1.0.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: build>=1.0.0; extra == 'dev'
30
+ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
31
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
32
+ Requires-Dist: rich>=13.0.0; extra == 'dev'
33
+ Requires-Dist: twine>=4.0.0; extra == 'dev'
34
+ Provides-Extra: rich
35
+ Requires-Dist: rich>=13.0.0; extra == 'rich'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # env-guard
39
+
40
+ **Zero-config environment variable validation for Python.**
41
+
42
+ Stop writing boilerplate. Your `.env.example` already documents your config — let it validate too.
43
+
44
+ ## Why env-guard?
45
+
46
+ Most environment validation libraries require you to define your schema twice:
47
+
48
+ ```python
49
+ # With Pydantic Settings (the old way)
50
+ class Settings(BaseSettings):
51
+ PORT: int
52
+ DEBUG: bool = False
53
+ DATABASE_URL: str
54
+ API_KEY: str
55
+ REDIS_HOST: str = "localhost"
56
+ REDIS_PORT: int = 6379
57
+ ```
58
+
59
+ But you already have this information in `.env.example`:
60
+
61
+ ```bash
62
+ PORT=8000
63
+ DEBUG=false
64
+ DATABASE_URL=postgresql://localhost/myapp
65
+ API_KEY=your-api-key-here
66
+ REDIS_HOST=localhost
67
+ REDIS_PORT=6379
68
+ ```
69
+
70
+ **Why write it twice?**
71
+
72
+ With env-guard, you don't. One line validates everything:
73
+
74
+ ```python
75
+ from env_guard import validate_env
76
+
77
+ validate_env() # That's it. Zero config.
78
+ ```
79
+
80
+ ## Features
81
+
82
+ - **Zero configuration** — Types are inferred from `.env.example` values
83
+ - **Fail fast** — All errors reported at once with beautiful output
84
+ - **Type coercion** — Get typed values (`int`, `bool`, `float`, `str`)
85
+ - **Optional variables** — Mark with `# optional` comment
86
+ - **Rich output** — Colorized error reports (with fallback for minimal installs)
87
+ - **CI-friendly** — Exit code 1 on failure, works in any pipeline
88
+
89
+ ## Installation
90
+
91
+ ```bash
92
+ pip install environment-guard
93
+ ```
94
+
95
+ For colorized output with Rich:
96
+
97
+ ```bash
98
+ pip install environment-guard[rich]
99
+ ```
100
+
101
+ ## Quick Start
102
+
103
+ ### 1. Create your `.env.example`
104
+
105
+ ```bash
106
+ # .env.example
107
+ PORT=8000
108
+ DEBUG=false
109
+ DATABASE_URL=postgresql://localhost/myapp
110
+ API_KEY=your-secret-key
111
+ WEBHOOK_URL=https://example.com/hook # optional
112
+ ```
113
+
114
+ ### 2. Validate on startup
115
+
116
+ ```python
117
+ # app.py
118
+ from env_guard import validate_env
119
+
120
+ # Validates against .env.example, exits with code 1 on failure
121
+ validate_env()
122
+
123
+ # Your app starts here...
124
+ ```
125
+
126
+ ### 3. Get typed values (optional)
127
+
128
+ ```python
129
+ from env_guard import validate
130
+
131
+ env = validate()
132
+
133
+ print(env["PORT"]) # 8000 (int, not str!)
134
+ print(env["DEBUG"]) # False (bool)
135
+ print(env["DATABASE_URL"]) # "postgresql://..." (str)
136
+ ```
137
+
138
+ ## Type Inference Rules
139
+
140
+ env-guard infers types from your example values:
141
+
142
+ | Example Value | Inferred Type |
143
+ |---------------|---------------|
144
+ | `8000` | `int` |
145
+ | `3.14` | `float` |
146
+ | `true`, `false`, `yes`, `no` | `bool` |
147
+ | Everything else | `str` |
148
+
149
+ ## Optional Variables
150
+
151
+ Mark variables as optional with a comment:
152
+
153
+ ```bash
154
+ REQUIRED_VAR=value
155
+ OPTIONAL_VAR=default # optional
156
+ ALSO_OPTIONAL=123 # ?
157
+ NOT_REQUIRED=true # not required
158
+ ```
159
+
160
+ Optional variables won't cause validation failures if missing.
161
+
162
+ ## API Reference
163
+
164
+ ### `validate_env()`
165
+
166
+ Main validation function. Prints results and exits on failure.
167
+
168
+ ```python
169
+ from env_guard import validate_env
170
+
171
+ result = validate_env(
172
+ example_path=".env.example", # Path to example file
173
+ exit_on_error=True, # Exit with code 1 on failure
174
+ quiet=False, # Suppress output
175
+ )
176
+ ```
177
+
178
+ ### `validate()`
179
+
180
+ Returns typed values or raises `EnvValidationError`.
181
+
182
+ ```python
183
+ from env_guard import validate, EnvValidationError
184
+
185
+ try:
186
+ env = validate()
187
+ port = env["PORT"] # Already an int!
188
+ except EnvValidationError as e:
189
+ print(f"Missing: {[err.var_name for err in e.result.errors]}")
190
+ ```
191
+
192
+ ### CLI
193
+
194
+ ```bash
195
+ # Validate using .env.example in current directory
196
+ env-guard
197
+
198
+ # Specify a different file
199
+ env-guard -e config/.env.example
200
+
201
+ # Quiet mode (only show errors)
202
+ env-guard -q
203
+
204
+ # Don't exit with error code (useful for scripts)
205
+ env-guard --no-exit
206
+ ```
207
+
208
+ ## Error Output
209
+
210
+ When validation fails, you get a clear, grouped error report:
211
+
212
+ ```
213
+ ╭─────────────────────────────────────────────────────────────╮
214
+ │ Environment validation failed with 3 error(s) │
215
+ ╰─────────────────────────────────────────────────────────────╯
216
+
217
+ ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓
218
+ ┃ Missing Variables ┃
219
+ ┣━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━┫
220
+ ┃ Variable ┃ Expected Type┃
221
+ ┣━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━┫
222
+ ┃ API_KEY ┃ string ┃
223
+ ┃ DATABASE_URL ┃ string ┃
224
+ ┗━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┛
225
+
226
+ ┏━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
227
+ ┃ Type Mismatches ┃
228
+ ┣━━━━━━━━━━━━━━━━━╋━━━━━━━━━━╋━━━━━━━━━━━━━━━┫
229
+ ┃ Variable ┃ Expected ┃ Got ┃
230
+ ┣━━━━━━━━━━━━━━━━━╋━━━━━━━━━━╋━━━━━━━━━━━━━━━┫
231
+ ┃ PORT ┃ integer ┃ 'not-a-port' ┃
232
+ ┗━━━━━━━━━━━━━━━━━┻━━━━━━━━━━┻━━━━━━━━━━━━━━━┛
233
+
234
+ Hint: Set missing variables or fix type mismatches in your environment.
235
+ ```
236
+
237
+ ## Integration Examples
238
+
239
+ ### FastAPI
240
+
241
+ ```python
242
+ # main.py
243
+ from fastapi import FastAPI
244
+ from env_guard import validate
245
+
246
+ # Validate before app creation
247
+ env = validate()
248
+
249
+ app = FastAPI()
250
+
251
+ @app.get("/")
252
+ def root():
253
+ return {"port": env["PORT"], "debug": env["DEBUG"]}
254
+ ```
255
+
256
+ ### Flask
257
+
258
+ ```python
259
+ # app.py
260
+ from flask import Flask
261
+ from env_guard import validate_env
262
+
263
+ validate_env() # Fails fast if env is misconfigured
264
+
265
+ app = Flask(__name__)
266
+ ```
267
+
268
+ ### Django
269
+
270
+ ```python
271
+ # settings.py
272
+ from env_guard import validate
273
+
274
+ env = validate()
275
+
276
+ DEBUG = env["DEBUG"]
277
+ DATABASES = {
278
+ "default": {
279
+ "ENGINE": "django.db.backends.postgresql",
280
+ "NAME": env["DATABASE_NAME"],
281
+ }
282
+ }
283
+ ```
284
+
285
+ ### Docker / CI
286
+
287
+ ```dockerfile
288
+ # Dockerfile
289
+ FROM python:3.12-slim
290
+ COPY . /app
291
+ WORKDIR /app
292
+ RUN pip install .
293
+ # Validate env before starting
294
+ CMD ["sh", "-c", "env-guard && python main.py"]
295
+ ```
296
+
297
+ ```yaml
298
+ # GitHub Actions
299
+ - name: Validate environment
300
+ run: env-guard
301
+ env:
302
+ PORT: 8000
303
+ DEBUG: false
304
+ API_KEY: ${{ secrets.API_KEY }}
305
+ ```
306
+
307
+ ## Comparison
308
+
309
+ | Feature | env-guard | pydantic-settings | environs |
310
+ |---------|-----------|-------------------|----------|
311
+ | Zero config | ✅ | ❌ | ❌ |
312
+ | Type inference | ✅ | ❌ | ❌ |
313
+ | Single source of truth | ✅ | ❌ | ❌ |
314
+ | Grouped error output | ✅ | ❌ | ❌ |
315
+ | No dependencies* | ✅ | ❌ | ❌ |
316
+ | Typed return values | ✅ | ✅ | ✅ |
317
+ | Nested config | ❌ | ✅ | ✅ |
318
+ | Complex validation | ❌ | ✅ | ✅ |
319
+
320
+ *Only requires `python-dotenv`. Rich is optional.
321
+
322
+ ## When NOT to Use env-guard
323
+
324
+ env-guard is designed for simplicity. If you need:
325
+
326
+ - **Complex validation rules** (regex, min/max, custom validators)
327
+ - **Nested configuration** (YAML-style hierarchies)
328
+ - **Multiple environment files** (.env.local, .env.production)
329
+ - **Secret management integration** (AWS Secrets Manager, etc.)
330
+
331
+ Consider [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) or [dynaconf](https://www.dynaconf.com/) instead.
332
+
333
+ ## Contributing
334
+
335
+ Contributions welcome! Please read our contributing guidelines first.
336
+
337
+ ```bash
338
+ # Clone and install dev dependencies
339
+ git clone https://github.com/ljo3/env-guard.git
340
+ cd env-guard
341
+ pip install -e ".[dev]"
342
+
343
+ # Run tests
344
+ pytest
345
+
346
+ # Run tests with coverage
347
+ pytest --cov=env_guard
348
+ ```
349
+
350
+ ## License
351
+
352
+ MIT License - see [LICENSE](LICENSE) for details.
@@ -0,0 +1,7 @@
1
+ env_guard/__init__.py,sha256=ukkjbrvkhnuicjw4v2ZumnM0xsK48nvqvRYrXN5hfQo,1756
2
+ env_guard/validator.py,sha256=9Qadp3omFu1WLSITr2LFqWHcTCWkdpmagTVdXZwZQZE,14586
3
+ environment_guard-0.1.0.dist-info/METADATA,sha256=TBEWSxHOgGrmg1f3zRVwi6WzILUBZ1aFzBtbTUNa9oc,9437
4
+ environment_guard-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
5
+ environment_guard-0.1.0.dist-info/entry_points.txt,sha256=-2LE4AmMxmwr46ZKuyEBYwEM08Mhhf4lzSXflMZt9WM,45
6
+ environment_guard-0.1.0.dist-info/licenses/LICENSE,sha256=F-4b93u0OVrVwGXgMwBRq6MlGyUT9zmre1oh5Gft5Ts,1066
7
+ environment_guard-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ env-guard = env_guard:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Your Name
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.