envgap 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.
- envgap/__init__.py +3 -0
- envgap/checker.py +397 -0
- envgap/cli.py +66 -0
- envgap/extractors/__init__.py +2 -0
- envgap/extractors/dotenv.py +75 -0
- envgap/extractors/python_ast.py +120 -0
- envgap/model/__init__.py +13 -0
- envgap/model/env_source.py +29 -0
- envgap/model/expected_var.py +25 -0
- envgap/model/finding.py +25 -0
- envgap/reporters/__init__.py +2 -0
- envgap/reporters/json.py +67 -0
- envgap/reporters/terminal.py +183 -0
- envgap-0.1.0.dist-info/METADATA +319 -0
- envgap-0.1.0.dist-info/RECORD +18 -0
- envgap-0.1.0.dist-info/WHEEL +4 -0
- envgap-0.1.0.dist-info/entry_points.txt +2 -0
- envgap-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class EnvVar:
|
|
9
|
+
key: str
|
|
10
|
+
value: str
|
|
11
|
+
path: Path
|
|
12
|
+
line: int
|
|
13
|
+
raw: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class EnvFile:
|
|
18
|
+
path: Path
|
|
19
|
+
vars: list[EnvVar] = field(default_factory=list)
|
|
20
|
+
duplicates: dict[str, list[EnvVar]] = field(default_factory=dict)
|
|
21
|
+
parse_warnings: list[str] = field(default_factory=list)
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def values(self) -> dict[str, EnvVar]:
|
|
25
|
+
return {var.key: var for var in self.vars}
|
|
26
|
+
|
|
27
|
+
def contains(self, key: str) -> bool:
|
|
28
|
+
return key in self.values
|
|
29
|
+
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class CodeUsage:
|
|
9
|
+
key: str
|
|
10
|
+
path: Path
|
|
11
|
+
line: int
|
|
12
|
+
required: bool
|
|
13
|
+
source: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class ExpectedVar:
|
|
18
|
+
key: str
|
|
19
|
+
documented_in: Path | None = None
|
|
20
|
+
code_usages: list[CodeUsage] = field(default_factory=list)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def required(self) -> bool:
|
|
24
|
+
return any(usage.required for usage in self.code_usages)
|
|
25
|
+
|
envgap/model/finding.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Severity(str, Enum):
|
|
9
|
+
ERROR = "error"
|
|
10
|
+
WARNING = "warning"
|
|
11
|
+
INFO = "info"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Finding:
|
|
16
|
+
code: str
|
|
17
|
+
severity: Severity
|
|
18
|
+
title: str
|
|
19
|
+
message: str
|
|
20
|
+
key: str | None = None
|
|
21
|
+
path: Path | None = None
|
|
22
|
+
line: int | None = None
|
|
23
|
+
suggestion: str | None = None
|
|
24
|
+
details: list[str] = field(default_factory=list)
|
|
25
|
+
|
envgap/reporters/json.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from envgap.checker import CheckResult
|
|
8
|
+
from envgap.model import Finding, Severity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_json(result: CheckResult) -> str:
|
|
12
|
+
payload = {
|
|
13
|
+
"root": str(result.root),
|
|
14
|
+
"files": {
|
|
15
|
+
"env": _file_info(result.env_path),
|
|
16
|
+
"example": _file_info(result.example_path),
|
|
17
|
+
},
|
|
18
|
+
"shell": {
|
|
19
|
+
"checked": result.include_shell,
|
|
20
|
+
"expected_keys_found": sorted(key for key in result.expected_keys if key in result.shell_env),
|
|
21
|
+
"expected_keys_missing": sorted(key for key in result.expected_keys if key not in result.shell_env),
|
|
22
|
+
},
|
|
23
|
+
"summary": {
|
|
24
|
+
"findings": len(result.findings),
|
|
25
|
+
"errors": sum(1 for finding in result.findings if finding.severity == Severity.ERROR),
|
|
26
|
+
"warnings": sum(1 for finding in result.findings if finding.severity == Severity.WARNING),
|
|
27
|
+
"expected_keys": len(result.expected_keys),
|
|
28
|
+
"code_usages": len(result.code_usages),
|
|
29
|
+
},
|
|
30
|
+
"findings": [_finding_to_dict(finding, result.root) for finding in result.findings],
|
|
31
|
+
"code_usages": [
|
|
32
|
+
{
|
|
33
|
+
"key": usage.key,
|
|
34
|
+
"path": _relative(usage.path, result.root),
|
|
35
|
+
"line": usage.line,
|
|
36
|
+
"required": usage.required,
|
|
37
|
+
"source": usage.source,
|
|
38
|
+
}
|
|
39
|
+
for usage in result.code_usages
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _finding_to_dict(finding: Finding, root: Path) -> dict[str, Any]:
|
|
46
|
+
return {
|
|
47
|
+
"code": finding.code,
|
|
48
|
+
"severity": finding.severity.value,
|
|
49
|
+
"title": finding.title,
|
|
50
|
+
"message": finding.message,
|
|
51
|
+
"key": finding.key,
|
|
52
|
+
"path": _relative(finding.path, root) if finding.path else None,
|
|
53
|
+
"line": finding.line,
|
|
54
|
+
"suggestion": finding.suggestion,
|
|
55
|
+
"details": finding.details,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _file_info(path: Path) -> dict[str, Any]:
|
|
60
|
+
return {"path": str(path), "exists": path.exists()}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _relative(path: Path, root: Path) -> str:
|
|
64
|
+
try:
|
|
65
|
+
return str(path.relative_to(root))
|
|
66
|
+
except ValueError:
|
|
67
|
+
return str(path)
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from envgap.checker import CheckResult
|
|
8
|
+
from envgap.model import Finding, Severity
|
|
9
|
+
|
|
10
|
+
ICONS = {
|
|
11
|
+
Severity.ERROR: "!",
|
|
12
|
+
Severity.WARNING: "~",
|
|
13
|
+
Severity.INFO: "i",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
SEVERITY_ORDER = {
|
|
17
|
+
Severity.ERROR: 0,
|
|
18
|
+
Severity.WARNING: 1,
|
|
19
|
+
Severity.INFO: 2,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
DIAGNOSIS_LABELS = {
|
|
23
|
+
"code_missing_from_example": "Code/documentation drift",
|
|
24
|
+
"duplicate_key": "Duplicate key",
|
|
25
|
+
"empty_value": "Empty value",
|
|
26
|
+
"missing_env_file": "Missing source",
|
|
27
|
+
"missing_example_file": "Missing source",
|
|
28
|
+
"missing_key": "Missing value",
|
|
29
|
+
"parse_warning": "Parse warning",
|
|
30
|
+
"placeholder_value": "Placeholder value",
|
|
31
|
+
"possible_typo": "Possible typo",
|
|
32
|
+
"undocumented_key": "Undocumented key",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_terminal(result: CheckResult, strict: bool = False) -> str:
|
|
37
|
+
lines: list[str] = []
|
|
38
|
+
counts = Counter(finding.severity for finding in result.findings)
|
|
39
|
+
|
|
40
|
+
lines.append("envgap check")
|
|
41
|
+
lines.append("=" * 15)
|
|
42
|
+
lines.append("")
|
|
43
|
+
lines.append(f"Project: {result.root}")
|
|
44
|
+
lines.append("Checked:")
|
|
45
|
+
lines.append(f" shell environment: {_shell_summary(result)}")
|
|
46
|
+
lines.append(f" .env: {_dotenv_summary(result.env_path, len(result.env_file.vars))}")
|
|
47
|
+
lines.append(f" .env.example: {_dotenv_summary(result.example_path, len(result.example_file.vars))}")
|
|
48
|
+
lines.append(f" Python code: {len(result.code_usages)} env usage(s)")
|
|
49
|
+
lines.append("")
|
|
50
|
+
|
|
51
|
+
if not result.findings:
|
|
52
|
+
lines.append("OK No environment config issues found.")
|
|
53
|
+
lines.append(_exit_code_line(strict))
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
lines.append(
|
|
57
|
+
"Summary: "
|
|
58
|
+
f"{counts[Severity.ERROR]} error(s), "
|
|
59
|
+
f"{counts[Severity.WARNING]} warning(s), "
|
|
60
|
+
f"{counts[Severity.INFO]} info"
|
|
61
|
+
)
|
|
62
|
+
lines.append("")
|
|
63
|
+
lines.append("Diagnosis:")
|
|
64
|
+
|
|
65
|
+
for group, findings in _group_findings(result.findings).items():
|
|
66
|
+
lines.append(f" {group}")
|
|
67
|
+
for finding in sorted(findings, key=_finding_sort_key):
|
|
68
|
+
lines.extend(_format_finding(finding, result))
|
|
69
|
+
lines.append("")
|
|
70
|
+
|
|
71
|
+
lines.append(_exit_code_line(strict))
|
|
72
|
+
return "\n".join(lines).rstrip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _format_finding(finding: Finding, result: CheckResult) -> list[str]:
|
|
76
|
+
icon = ICONS[finding.severity]
|
|
77
|
+
location = _location(finding, result.root)
|
|
78
|
+
label = DIAGNOSIS_LABELS.get(finding.code, finding.code.replace("_", " ").title())
|
|
79
|
+
lines = [f" {icon} {label}: {finding.title}{location}", f" {finding.message}"]
|
|
80
|
+
checked = [detail for detail in finding.details if _is_checked_detail(detail)]
|
|
81
|
+
usages = [detail for detail in finding.details if not _is_checked_detail(detail)]
|
|
82
|
+
if checked:
|
|
83
|
+
lines.append(" Checked:")
|
|
84
|
+
for detail in checked:
|
|
85
|
+
lines.append(f" {detail}")
|
|
86
|
+
elif finding.key:
|
|
87
|
+
lines.extend(_checked_lines(finding.key, result))
|
|
88
|
+
for detail in usages:
|
|
89
|
+
lines.append(f" Used at: {_relative_detail(detail, result.root)}")
|
|
90
|
+
if finding.suggestion:
|
|
91
|
+
lines.append(f" Suggested fix: {finding.suggestion}")
|
|
92
|
+
return lines
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _group_findings(findings: list[Finding]) -> dict[str, list[Finding]]:
|
|
96
|
+
groups: dict[str, list[Finding]] = defaultdict(list)
|
|
97
|
+
for finding in findings:
|
|
98
|
+
groups[finding.key or "Project setup"].append(finding)
|
|
99
|
+
return {
|
|
100
|
+
key: groups[key]
|
|
101
|
+
for key in sorted(groups, key=lambda value: (value != "Project setup", value))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _finding_sort_key(finding: Finding) -> tuple[int, str, int]:
|
|
106
|
+
return (SEVERITY_ORDER[finding.severity], finding.code, finding.line or 0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _checked_lines(key: str, result: CheckResult) -> list[str]:
|
|
110
|
+
return [
|
|
111
|
+
" Checked:",
|
|
112
|
+
f" shell environment: {_shell_key_status(key, result)}",
|
|
113
|
+
f" .env: {_env_file_status(key, result.env_path, result.env_file.values)}",
|
|
114
|
+
f" .env.example: {_env_file_status(key, result.example_path, result.example_file.values)}",
|
|
115
|
+
f" Python code: {_code_status(key, result)}",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_checked_detail(detail: str) -> bool:
|
|
120
|
+
return detail.startswith(("shell environment:", ".env:", ".env.example:", "Python code:"))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _dotenv_summary(path: Path, count: int) -> str:
|
|
124
|
+
if not path.exists():
|
|
125
|
+
return "not found"
|
|
126
|
+
return f"found ({count} key(s))"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _shell_summary(result: CheckResult) -> str:
|
|
130
|
+
if not result.include_shell:
|
|
131
|
+
return "ignored (--no-shell)"
|
|
132
|
+
if not result.expected_keys:
|
|
133
|
+
return f"available ({len(result.shell_env)} variable(s))"
|
|
134
|
+
found = sum(1 for key in result.expected_keys if key in result.shell_env)
|
|
135
|
+
return f"available ({found}/{len(result.expected_keys)} expected key(s) found)"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _env_file_status(key: str, path: Path, values: dict) -> str:
|
|
139
|
+
if not path.exists():
|
|
140
|
+
return "file not found"
|
|
141
|
+
return "found" if key in values else "not found"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _shell_key_status(key: str, result: CheckResult) -> str:
|
|
145
|
+
if not result.include_shell:
|
|
146
|
+
return "ignored"
|
|
147
|
+
return "found" if key in result.shell_env else "not found"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _code_status(key: str, result: CheckResult) -> str:
|
|
151
|
+
usages = [usage for usage in result.code_usages if usage.key == key]
|
|
152
|
+
if not usages:
|
|
153
|
+
return "not found"
|
|
154
|
+
if any(usage.required for usage in usages):
|
|
155
|
+
return "required"
|
|
156
|
+
return "optional"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _exit_code_line(strict: bool) -> str:
|
|
160
|
+
if strict:
|
|
161
|
+
return "Exit code: 1 when errors or warnings are present (--strict/--ci), otherwise 0."
|
|
162
|
+
return "Exit code: 1 when errors are present, otherwise 0. Warnings do not fail unless --strict or --ci is used."
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _location(finding: Finding, root: Path) -> str:
|
|
166
|
+
if not finding.path:
|
|
167
|
+
return ""
|
|
168
|
+
path = _relative(finding.path, root)
|
|
169
|
+
if finding.line:
|
|
170
|
+
return f" ({path}:{finding.line})"
|
|
171
|
+
return f" ({path})"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _relative(path: Path, root: Path) -> str:
|
|
175
|
+
try:
|
|
176
|
+
return str(path.relative_to(root))
|
|
177
|
+
except ValueError:
|
|
178
|
+
return str(path)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _relative_detail(detail: str, root: Path) -> str:
|
|
182
|
+
root_text = str(root)
|
|
183
|
+
return detail.replace(root_text + "/", "")
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envgap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find the gaps in your Python environment config.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Pinak-Datta/envgap
|
|
6
|
+
Project-URL: Repository, https://github.com/Pinak-Datta/envgap
|
|
7
|
+
Project-URL: Issues, https://github.com/Pinak-Datta/envgap/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Pinak-Datta/envgap/blob/main/CHANGELOG.md
|
|
9
|
+
Author: envgap contributors
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cli,configuration,diagnostics,dotenv,environment
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
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 :: Quality Assurance
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# envgap
|
|
33
|
+
|
|
34
|
+
Find the gaps in your Python environment config.
|
|
35
|
+
|
|
36
|
+
`envgap` is a diagnostic CLI for Python projects that use `.env` files, `.env.example`, shell variables, and `os.environ` / `os.getenv` in code. It does not load your config. It shows the gaps between what your app expects, what your project documents, and what your environment actually provides.
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
## 30-Second Demo
|
|
41
|
+
|
|
42
|
+
Given a project with:
|
|
43
|
+
|
|
44
|
+
```dotenv
|
|
45
|
+
# .env
|
|
46
|
+
DB_URL=postgres://localhost/app
|
|
47
|
+
OPENAI_API_KEY=changeme
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
# app.py
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
DATABASE_URL = os.environ["DATABASE_URL"]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run:
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
$ DATABASE_URL=postgres://shell/app envgap check examples/basic
|
|
61
|
+
|
|
62
|
+
envgap check
|
|
63
|
+
===============
|
|
64
|
+
|
|
65
|
+
Checked:
|
|
66
|
+
shell environment: available (1/4 expected key(s) found)
|
|
67
|
+
.env: found (3 key(s))
|
|
68
|
+
.env.example: found (3 key(s))
|
|
69
|
+
Python code: 3 env usage(s)
|
|
70
|
+
|
|
71
|
+
Diagnosis:
|
|
72
|
+
DATABASE_URL
|
|
73
|
+
! Missing value: DATABASE_URL is missing from .env (app.py:3)
|
|
74
|
+
It is present in your shell environment, so local commands may work while CI or Docker still fails.
|
|
75
|
+
Suggested fix: Add DATABASE_URL=... to .env or document how CI/Docker should provide it.
|
|
76
|
+
|
|
77
|
+
DB_URL
|
|
78
|
+
~ Possible typo: DB_URL may be a typo for DATABASE_URL (.env:1)
|
|
79
|
+
Suggested fix: Rename DB_URL to DATABASE_URL if they represent the same setting.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Why
|
|
83
|
+
|
|
84
|
+
Environment config bugs are boring until they eat an afternoon.
|
|
85
|
+
|
|
86
|
+
Common examples:
|
|
87
|
+
|
|
88
|
+
- The app expects `DATABASE_URL`, but `.env` contains `DB_URL`.
|
|
89
|
+
- `.env.example` says a key exists, but local `.env` never got it.
|
|
90
|
+
- A secret is still set to `changeme`.
|
|
91
|
+
- A variable works locally only because it is exported in your shell.
|
|
92
|
+
- CI, Docker, or another developer's machine fails because the real required variables are not documented.
|
|
93
|
+
|
|
94
|
+
`envgap` is for that moment when you want the project to explain itself.
|
|
95
|
+
|
|
96
|
+
## Install
|
|
97
|
+
|
|
98
|
+
```console
|
|
99
|
+
pip install envgap
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
From a local checkout:
|
|
103
|
+
|
|
104
|
+
```console
|
|
105
|
+
pip install -e ".[dev]"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Quick Start
|
|
109
|
+
|
|
110
|
+
Run a check in the current project:
|
|
111
|
+
|
|
112
|
+
```console
|
|
113
|
+
envgap check
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Try the included broken example:
|
|
117
|
+
|
|
118
|
+
```console
|
|
119
|
+
envgap check examples/basic
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Show machine-readable output:
|
|
123
|
+
|
|
124
|
+
```console
|
|
125
|
+
envgap check --json
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Ignore shell variables for deterministic CI checks:
|
|
129
|
+
|
|
130
|
+
```console
|
|
131
|
+
envgap check --no-shell
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Fail on warnings as well as errors:
|
|
135
|
+
|
|
136
|
+
```console
|
|
137
|
+
envgap check --strict
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`--ci` is supported as a CI-friendly alias for `--strict`:
|
|
141
|
+
|
|
142
|
+
```console
|
|
143
|
+
envgap check --ci
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Use custom dotenv filenames:
|
|
147
|
+
|
|
148
|
+
```console
|
|
149
|
+
envgap check --env-file .env.local --example-file .env.example
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## What It Checks Today
|
|
153
|
+
|
|
154
|
+
`envgap check` currently inspects:
|
|
155
|
+
|
|
156
|
+
- current shell environment
|
|
157
|
+
- `.env`
|
|
158
|
+
- `.env.example`
|
|
159
|
+
- Python files using common environment variable APIs
|
|
160
|
+
|
|
161
|
+
It detects:
|
|
162
|
+
|
|
163
|
+
- missing keys
|
|
164
|
+
- undocumented extra keys
|
|
165
|
+
- duplicate keys
|
|
166
|
+
- empty values
|
|
167
|
+
- placeholder values like `your-key-here`, `changeme`, `todo`, and `replace-me`
|
|
168
|
+
- likely typo pairs like `DB_URL` vs `DATABASE_URL`
|
|
169
|
+
- required env vars used in Python code but missing from `.env.example`
|
|
170
|
+
- missing `.env`
|
|
171
|
+
- missing `.env.example`
|
|
172
|
+
|
|
173
|
+
It scans Python code for:
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
os.environ["DATABASE_URL"]
|
|
177
|
+
os.getenv("DATABASE_URL")
|
|
178
|
+
os.getenv("DATABASE_URL", "sqlite:///local.db")
|
|
179
|
+
os.environ.get("DATABASE_URL", "sqlite:///local.db")
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Required vs optional behavior:
|
|
183
|
+
|
|
184
|
+
- `os.environ["KEY"]` is required
|
|
185
|
+
- `os.getenv("KEY")` is required
|
|
186
|
+
- `os.getenv("KEY", default)` is optional
|
|
187
|
+
- `os.environ.get("KEY", default)` is optional
|
|
188
|
+
|
|
189
|
+
## Exit Codes
|
|
190
|
+
|
|
191
|
+
| Command | Exit code behavior |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `envgap check` | exits `1` when errors are present |
|
|
194
|
+
| `envgap check --strict` | exits `1` when errors or warnings are present |
|
|
195
|
+
| `envgap check --ci` | same as `--strict` |
|
|
196
|
+
| `envgap check --json` | same pass/fail behavior, JSON output |
|
|
197
|
+
| `envgap check --no-shell` | ignores current shell variables when diagnosing missing keys |
|
|
198
|
+
|
|
199
|
+
Warnings do not fail a normal check unless `--strict` or `--ci` is used.
|
|
200
|
+
|
|
201
|
+
## CI
|
|
202
|
+
|
|
203
|
+
```yaml
|
|
204
|
+
name: envgap
|
|
205
|
+
|
|
206
|
+
on: [push, pull_request]
|
|
207
|
+
|
|
208
|
+
jobs:
|
|
209
|
+
envgap:
|
|
210
|
+
runs-on: ubuntu-latest
|
|
211
|
+
steps:
|
|
212
|
+
- uses: actions/checkout@v4
|
|
213
|
+
- uses: actions/setup-python@v5
|
|
214
|
+
with:
|
|
215
|
+
python-version: "3.12"
|
|
216
|
+
- run: pip install envgap
|
|
217
|
+
- run: envgap check --ci
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Example Diagnosis
|
|
221
|
+
|
|
222
|
+
Given:
|
|
223
|
+
|
|
224
|
+
```dotenv
|
|
225
|
+
# .env
|
|
226
|
+
DB_URL=postgres://localhost/app
|
|
227
|
+
OPENAI_API_KEY=changeme
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
```dotenv
|
|
231
|
+
# .env.example
|
|
232
|
+
DATABASE_URL=
|
|
233
|
+
OPENAI_API_KEY=your-key-here
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
# app.py
|
|
238
|
+
import os
|
|
239
|
+
|
|
240
|
+
DATABASE_URL = os.environ["DATABASE_URL"]
|
|
241
|
+
DEBUG = os.getenv("DEBUG", "false")
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`envgap` can report:
|
|
245
|
+
|
|
246
|
+
- `DATABASE_URL` is required in code but missing from `.env`
|
|
247
|
+
- `DB_URL` may be a typo for `DATABASE_URL`
|
|
248
|
+
- `OPENAI_API_KEY` still looks like a placeholder
|
|
249
|
+
- `DEBUG` is optional because it has a default
|
|
250
|
+
|
|
251
|
+
## Why Not Just python-dotenv?
|
|
252
|
+
|
|
253
|
+
`python-dotenv` loads environment variables.
|
|
254
|
+
|
|
255
|
+
`envgap` explains whether the environment variables your app expects match the variables your project defines and documents.
|
|
256
|
+
|
|
257
|
+
The useful question is not only:
|
|
258
|
+
|
|
259
|
+
> Did `.env` load?
|
|
260
|
+
|
|
261
|
+
It is:
|
|
262
|
+
|
|
263
|
+
> What does my app expect, where should it come from, and why is it missing or wrong here?
|
|
264
|
+
|
|
265
|
+
## Current Scope
|
|
266
|
+
|
|
267
|
+
This is intentionally a small diagnostic tool, not a config framework.
|
|
268
|
+
|
|
269
|
+
In scope now:
|
|
270
|
+
|
|
271
|
+
- `.env`
|
|
272
|
+
- `.env.example`
|
|
273
|
+
- shell environment
|
|
274
|
+
- Python `os.environ` / `os.getenv` scanning
|
|
275
|
+
- terminal and JSON reports
|
|
276
|
+
- CI-friendly exit codes
|
|
277
|
+
|
|
278
|
+
Not in scope yet:
|
|
279
|
+
|
|
280
|
+
- loading or mutating your environment
|
|
281
|
+
- validating every framework-specific settings pattern
|
|
282
|
+
- Docker Compose parsing
|
|
283
|
+
- GitHub Actions secrets parsing
|
|
284
|
+
- Pydantic `BaseSettings` extraction
|
|
285
|
+
|
|
286
|
+
## Roadmap
|
|
287
|
+
|
|
288
|
+
- Pydantic `BaseSettings` support
|
|
289
|
+
- Django settings helper detection
|
|
290
|
+
- Docker Compose env detection
|
|
291
|
+
- GitHub Actions env/secrets detection
|
|
292
|
+
- precedence explanations for shell vs `.env` vs framework defaults
|
|
293
|
+
- GitHub Actions annotations
|
|
294
|
+
- richer JSON schema for editor and CI integrations
|
|
295
|
+
|
|
296
|
+
## Development
|
|
297
|
+
|
|
298
|
+
```console
|
|
299
|
+
python -m venv .venv
|
|
300
|
+
. .venv/bin/activate
|
|
301
|
+
pip install -e ".[dev]"
|
|
302
|
+
pytest
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Run the example locally:
|
|
306
|
+
|
|
307
|
+
```console
|
|
308
|
+
envgap check examples/basic
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Run the shell-aware example:
|
|
312
|
+
|
|
313
|
+
```console
|
|
314
|
+
DATABASE_URL=postgres://shell/app envgap check examples/basic
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## License
|
|
318
|
+
|
|
319
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
envgap/__init__.py,sha256=pGzI4Jg8YQnLWnSukTFYR0bAehgUoJ3ZqIjAEIcqMMI,77
|
|
2
|
+
envgap/checker.py,sha256=gaKZuG4r8jMObbVfsu2XW7SuZQ0zDgivluP-vc_e2z8,14489
|
|
3
|
+
envgap/cli.py,sha256=KmG7F3CWt5MFIbChOIWXEITJfJRVDp9rPakD5wGedm0,2592
|
|
4
|
+
envgap/extractors/__init__.py,sha256=nACaqCNkydmDvzoE6-JVgRtnWrY3Zkch-8Mk2vET6ZI,77
|
|
5
|
+
envgap/extractors/dotenv.py,sha256=5CEJA2X3NGkZCQ4mpU0I3_U20XLrOwZ9wsxF94IVJ9s,2132
|
|
6
|
+
envgap/extractors/python_ast.py,sha256=utf8UOOpe8bQCd2LRFKrgpRDOCB8RBVXKXQHzjqgZi8,3381
|
|
7
|
+
envgap/model/__init__.py,sha256=XR_3UlT31a1Q4Y2IdlIxew5xc_-2U4yOxMB9XhLpVBs,276
|
|
8
|
+
envgap/model/env_source.py,sha256=B8JvXQteTjO0ij319BdBUIejxVC77qiunZAtWXufHw4,625
|
|
9
|
+
envgap/model/expected_var.py,sha256=PnBdvCg06U6PhJtfc4ILbGVL92hggqPfwvM0jaY7L-I,481
|
|
10
|
+
envgap/model/finding.py,sha256=vW3i5L7dnJs8DkDvUQCo06uN9H1zewnRojKHUOj6oL8,497
|
|
11
|
+
envgap/reporters/__init__.py,sha256=my7j9pg8jRzUEUX8o7LOoqmGCPoANyMlhRufIoGG8HU,36
|
|
12
|
+
envgap/reporters/json.py,sha256=4sl5YwrkheBCI9mjF5NooAmuXdBbaanJeEF8FM7kND4,2265
|
|
13
|
+
envgap/reporters/terminal.py,sha256=2AhId3bAyS7R4WHwswo-U3M5DVjRjQY_sRtGcGGo0Kg,6220
|
|
14
|
+
envgap-0.1.0.dist-info/METADATA,sha256=QUqKJz4Umd1R7gIX2RLYfMHeL8AJPyBR5ZrB58RMry8,7472
|
|
15
|
+
envgap-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
16
|
+
envgap-0.1.0.dist-info/entry_points.txt,sha256=n3ti1tfkj0SoYffpqS6Yl1QjhUh8iEtnWalN7JAN0U4,43
|
|
17
|
+
envgap-0.1.0.dist-info/licenses/LICENSE,sha256=czunO6Ct_QtfNBeUU5BNSUvz8lNkni5wzoWcJavSusM,1077
|
|
18
|
+
envgap-0.1.0.dist-info/RECORD,,
|