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
envgap/__init__.py
ADDED
envgap/checker.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from envgap.extractors.dotenv import parse_dotenv
|
|
9
|
+
from envgap.extractors.python_ast import scan_python_env_usage
|
|
10
|
+
from envgap.model import CodeUsage, EnvFile, ExpectedVar, Finding, Severity
|
|
11
|
+
|
|
12
|
+
PLACEHOLDER_VALUES = {
|
|
13
|
+
"",
|
|
14
|
+
"<your-key-here>",
|
|
15
|
+
"changeme",
|
|
16
|
+
"change-me",
|
|
17
|
+
"example",
|
|
18
|
+
"insert-key-here",
|
|
19
|
+
"replace-me",
|
|
20
|
+
"todo",
|
|
21
|
+
"your-api-key",
|
|
22
|
+
"your-key",
|
|
23
|
+
"your-key-here",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ABBREVIATIONS = {
|
|
27
|
+
"db": "database",
|
|
28
|
+
"cfg": "config",
|
|
29
|
+
"conf": "config",
|
|
30
|
+
"pwd": "password",
|
|
31
|
+
"uri": "url",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CheckResult:
|
|
37
|
+
root: Path
|
|
38
|
+
env_path: Path
|
|
39
|
+
example_path: Path
|
|
40
|
+
env_file: EnvFile
|
|
41
|
+
example_file: EnvFile
|
|
42
|
+
shell_env: dict[str, str]
|
|
43
|
+
include_shell: bool
|
|
44
|
+
expected_keys: set[str]
|
|
45
|
+
code_usages: list[CodeUsage]
|
|
46
|
+
findings: list[Finding]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def has_errors(self) -> bool:
|
|
50
|
+
return any(finding.severity == Severity.ERROR for finding in self.findings)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def has_warnings(self) -> bool:
|
|
54
|
+
return any(finding.severity == Severity.WARNING for finding in self.findings)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_check(
|
|
58
|
+
root: Path,
|
|
59
|
+
env_file: str = ".env",
|
|
60
|
+
example_file: str = ".env.example",
|
|
61
|
+
environ: Mapping[str, str] | None = None,
|
|
62
|
+
include_shell: bool = True,
|
|
63
|
+
) -> CheckResult:
|
|
64
|
+
root = root.resolve()
|
|
65
|
+
env_path = root / env_file
|
|
66
|
+
example_path = root / example_file
|
|
67
|
+
actual = parse_dotenv(env_path)
|
|
68
|
+
example = parse_dotenv(example_path)
|
|
69
|
+
shell_env = dict(os.environ if environ is None else environ) if include_shell else {}
|
|
70
|
+
code_usages = scan_python_env_usage(root)
|
|
71
|
+
expected = _expected_vars(example, code_usages)
|
|
72
|
+
findings = _build_findings(actual, example, expected, code_usages, shell_env, include_shell)
|
|
73
|
+
|
|
74
|
+
return CheckResult(
|
|
75
|
+
root=root,
|
|
76
|
+
env_path=env_path,
|
|
77
|
+
example_path=example_path,
|
|
78
|
+
env_file=actual,
|
|
79
|
+
example_file=example,
|
|
80
|
+
shell_env=shell_env,
|
|
81
|
+
include_shell=include_shell,
|
|
82
|
+
expected_keys=set(expected),
|
|
83
|
+
code_usages=code_usages,
|
|
84
|
+
findings=findings,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _expected_vars(example: EnvFile, code_usages: list[CodeUsage]) -> dict[str, ExpectedVar]:
|
|
89
|
+
expected: dict[str, ExpectedVar] = {}
|
|
90
|
+
for var in example.vars:
|
|
91
|
+
expected.setdefault(var.key, ExpectedVar(key=var.key, documented_in=var.path))
|
|
92
|
+
for usage in code_usages:
|
|
93
|
+
expected.setdefault(usage.key, ExpectedVar(key=usage.key)).code_usages.append(usage)
|
|
94
|
+
return expected
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _build_findings(
|
|
98
|
+
actual: EnvFile,
|
|
99
|
+
example: EnvFile,
|
|
100
|
+
expected: dict[str, ExpectedVar],
|
|
101
|
+
code_usages: list[CodeUsage],
|
|
102
|
+
shell_env: dict[str, str],
|
|
103
|
+
include_shell: bool,
|
|
104
|
+
) -> list[Finding]:
|
|
105
|
+
findings: list[Finding] = []
|
|
106
|
+
actual_values = actual.values
|
|
107
|
+
example_values = example.values
|
|
108
|
+
expected_keys = set(expected)
|
|
109
|
+
actual_keys = set(actual_values)
|
|
110
|
+
example_keys = set(example_values)
|
|
111
|
+
|
|
112
|
+
if not example.path.exists():
|
|
113
|
+
findings.append(
|
|
114
|
+
Finding(
|
|
115
|
+
code="missing_example_file",
|
|
116
|
+
severity=Severity.WARNING,
|
|
117
|
+
title=".env.example was not found",
|
|
118
|
+
message="envgap could not find a documented source of expected variables.",
|
|
119
|
+
path=example.path,
|
|
120
|
+
suggestion="Add a .env.example file with every environment variable your app expects.",
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if not actual.path.exists():
|
|
125
|
+
findings.append(
|
|
126
|
+
Finding(
|
|
127
|
+
code="missing_env_file",
|
|
128
|
+
severity=Severity.WARNING,
|
|
129
|
+
title=".env was not found",
|
|
130
|
+
message="envgap could not compare local values because .env is missing.",
|
|
131
|
+
path=actual.path,
|
|
132
|
+
suggestion="Create .env from .env.example, or pass --env-file for a different file.",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
findings.extend(_duplicate_findings(actual))
|
|
137
|
+
findings.extend(_duplicate_findings(example))
|
|
138
|
+
|
|
139
|
+
for key in sorted(expected_keys - actual_keys):
|
|
140
|
+
expected_var = expected[key]
|
|
141
|
+
if not expected_var.required and not expected_var.documented_in:
|
|
142
|
+
continue
|
|
143
|
+
usage = _first_required_usage(expected_var.code_usages)
|
|
144
|
+
findings.append(
|
|
145
|
+
Finding(
|
|
146
|
+
code="missing_key",
|
|
147
|
+
severity=Severity.ERROR if expected_var.required or expected_var.documented_in else Severity.WARNING,
|
|
148
|
+
title=f"{key} is missing from .env",
|
|
149
|
+
message=_missing_message(key, expected_var, shell_env, include_shell),
|
|
150
|
+
key=key,
|
|
151
|
+
path=usage.path if usage else expected_var.documented_in,
|
|
152
|
+
line=usage.line if usage else None,
|
|
153
|
+
suggestion=_missing_suggestion(key, shell_env, include_shell),
|
|
154
|
+
details=_checked_details(key, actual, example, shell_env, include_shell) + _usage_details(expected_var.code_usages),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
for key in sorted(actual_keys - expected_keys):
|
|
159
|
+
findings.append(
|
|
160
|
+
Finding(
|
|
161
|
+
code="undocumented_key",
|
|
162
|
+
severity=Severity.WARNING,
|
|
163
|
+
title=f"{key} is present but undocumented",
|
|
164
|
+
message=f"{key} exists in .env but is not in .env.example and was not found in Python env usage.",
|
|
165
|
+
key=key,
|
|
166
|
+
path=actual_values[key].path,
|
|
167
|
+
line=actual_values[key].line,
|
|
168
|
+
suggestion=f"Add {key} to .env.example, or remove it from .env if it is unused.",
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
for key, var in sorted(actual_values.items()):
|
|
173
|
+
if var.value == "":
|
|
174
|
+
findings.append(
|
|
175
|
+
Finding(
|
|
176
|
+
code="empty_value",
|
|
177
|
+
severity=Severity.ERROR if key in expected_keys else Severity.WARNING,
|
|
178
|
+
title=f"{key} is empty",
|
|
179
|
+
message=f"{key} is defined in .env but has no value.",
|
|
180
|
+
key=key,
|
|
181
|
+
path=var.path,
|
|
182
|
+
line=var.line,
|
|
183
|
+
suggestion=f"Set a value for {key}, or remove it if the app should use a default.",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
elif _is_placeholder(var.value):
|
|
187
|
+
findings.append(
|
|
188
|
+
Finding(
|
|
189
|
+
code="placeholder_value",
|
|
190
|
+
severity=Severity.ERROR if key in expected_keys else Severity.WARNING,
|
|
191
|
+
title=f"{key} still looks like a placeholder",
|
|
192
|
+
message=f"{key} is set to {_mask_value(key, var.value)}, which does not look like a real value.",
|
|
193
|
+
key=key,
|
|
194
|
+
path=var.path,
|
|
195
|
+
line=var.line,
|
|
196
|
+
suggestion=f"Replace {key} with the real value for this environment.",
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
for key in sorted(_required_code_keys(code_usages) - example_keys):
|
|
201
|
+
usage = next(usage for usage in code_usages if usage.key == key and usage.required)
|
|
202
|
+
findings.append(
|
|
203
|
+
Finding(
|
|
204
|
+
code="code_missing_from_example",
|
|
205
|
+
severity=Severity.ERROR,
|
|
206
|
+
title=f"{key} is used in code but missing from .env.example",
|
|
207
|
+
message=f"{key} is required by {usage.source} but is not documented in .env.example.",
|
|
208
|
+
key=key,
|
|
209
|
+
path=usage.path,
|
|
210
|
+
line=usage.line,
|
|
211
|
+
suggestion=f"Add {key}=... to .env.example so local dev and CI know it is required.",
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
findings.extend(_typo_findings(actual_keys, expected_keys, actual))
|
|
216
|
+
findings.extend(_parse_warning_findings(actual))
|
|
217
|
+
findings.extend(_parse_warning_findings(example))
|
|
218
|
+
return sorted(findings, key=lambda f: (f.severity.value, f.code, f.key or "", str(f.path or ""), f.line or 0))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _duplicate_findings(env_file: EnvFile) -> list[Finding]:
|
|
222
|
+
findings: list[Finding] = []
|
|
223
|
+
for key, vars_ in sorted(env_file.duplicates.items()):
|
|
224
|
+
lines = ", ".join(str(var.line) for var in vars_)
|
|
225
|
+
findings.append(
|
|
226
|
+
Finding(
|
|
227
|
+
code="duplicate_key",
|
|
228
|
+
severity=Severity.ERROR,
|
|
229
|
+
title=f"{key} is duplicated in {env_file.path.name}",
|
|
230
|
+
message=f"{key} appears more than once in {env_file.path.name} on lines {lines}. The last value wins in many loaders.",
|
|
231
|
+
key=key,
|
|
232
|
+
path=env_file.path,
|
|
233
|
+
line=vars_[0].line,
|
|
234
|
+
suggestion=f"Keep one {key} entry in {env_file.path.name}.",
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return findings
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _typo_findings(actual_keys: set[str], expected_keys: set[str], actual: EnvFile) -> list[Finding]:
|
|
241
|
+
findings: list[Finding] = []
|
|
242
|
+
for actual_key in sorted(actual_keys - expected_keys):
|
|
243
|
+
for expected_key in sorted(expected_keys - actual_keys):
|
|
244
|
+
if _similar(actual_key, expected_key):
|
|
245
|
+
var = actual.values[actual_key]
|
|
246
|
+
findings.append(
|
|
247
|
+
Finding(
|
|
248
|
+
code="possible_typo",
|
|
249
|
+
severity=Severity.WARNING,
|
|
250
|
+
title=f"{actual_key} may be a typo for {expected_key}",
|
|
251
|
+
message=f".env contains {actual_key}, but the expected variable is {expected_key}.",
|
|
252
|
+
key=actual_key,
|
|
253
|
+
path=var.path,
|
|
254
|
+
line=var.line,
|
|
255
|
+
suggestion=f"Rename {actual_key} to {expected_key} if they represent the same setting.",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
return findings
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _parse_warning_findings(env_file: EnvFile) -> list[Finding]:
|
|
262
|
+
return [
|
|
263
|
+
Finding(
|
|
264
|
+
code="parse_warning",
|
|
265
|
+
severity=Severity.WARNING,
|
|
266
|
+
title="Could not parse dotenv line",
|
|
267
|
+
message=warning,
|
|
268
|
+
path=env_file.path,
|
|
269
|
+
)
|
|
270
|
+
for warning in env_file.parse_warnings
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _first_required_usage(usages: list[CodeUsage]) -> CodeUsage | None:
|
|
275
|
+
return next((usage for usage in usages if usage.required), usages[0] if usages else None)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _required_code_keys(usages: list[CodeUsage]) -> set[str]:
|
|
279
|
+
return {usage.key for usage in usages if usage.required}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _missing_message(key: str, expected_var: ExpectedVar, shell_env: dict[str, str], include_shell: bool) -> str:
|
|
283
|
+
required_usage = _first_required_usage(expected_var.code_usages)
|
|
284
|
+
shell_note = ""
|
|
285
|
+
if include_shell and key in shell_env:
|
|
286
|
+
shell_note = " It is present in your shell environment, so local commands may work while CI or Docker still fails."
|
|
287
|
+
if required_usage:
|
|
288
|
+
return f"{key} is required by {required_usage.source} in {required_usage.path.name}:{required_usage.line} but was not found in .env.{shell_note}"
|
|
289
|
+
if expected_var.documented_in:
|
|
290
|
+
return f"{key} is documented in {expected_var.documented_in.name} but was not found in .env.{shell_note}"
|
|
291
|
+
return f"{key} is expected but was not found in .env.{shell_note}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _missing_suggestion(key: str, shell_env: dict[str, str], include_shell: bool) -> str:
|
|
295
|
+
if include_shell and key in shell_env:
|
|
296
|
+
return f"Add {key}=... to .env or document how CI/Docker should provide it."
|
|
297
|
+
return f"Add {key}=... to .env."
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _checked_details(
|
|
301
|
+
key: str,
|
|
302
|
+
actual: EnvFile,
|
|
303
|
+
example: EnvFile,
|
|
304
|
+
shell_env: dict[str, str],
|
|
305
|
+
include_shell: bool,
|
|
306
|
+
) -> list[str]:
|
|
307
|
+
return [
|
|
308
|
+
f"shell environment: {_shell_status(key, shell_env, include_shell)}",
|
|
309
|
+
f".env: {_env_file_status(key, actual)}",
|
|
310
|
+
f".env.example: {_env_file_status(key, example)}",
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _shell_status(key: str, shell_env: dict[str, str], include_shell: bool) -> str:
|
|
315
|
+
if not include_shell:
|
|
316
|
+
return "ignored"
|
|
317
|
+
return "found" if key in shell_env else "not found"
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _env_file_status(key: str, env_file: EnvFile) -> str:
|
|
321
|
+
if not env_file.path.exists():
|
|
322
|
+
return "file not found"
|
|
323
|
+
if key not in env_file.values:
|
|
324
|
+
return "not found"
|
|
325
|
+
value = env_file.values[key].value
|
|
326
|
+
if value == "":
|
|
327
|
+
return "found empty"
|
|
328
|
+
if _is_placeholder(value):
|
|
329
|
+
return "found placeholder"
|
|
330
|
+
return "found"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _usage_details(usages: list[CodeUsage]) -> list[str]:
|
|
334
|
+
return [
|
|
335
|
+
f"{usage.path}:{usage.line}: {usage.source} ({'required' if usage.required else 'optional'})"
|
|
336
|
+
for usage in usages
|
|
337
|
+
]
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _is_placeholder(value: str) -> bool:
|
|
341
|
+
normalized = value.strip().lower()
|
|
342
|
+
if normalized in PLACEHOLDER_VALUES:
|
|
343
|
+
return True
|
|
344
|
+
return "your-" in normalized or normalized.startswith("<") and normalized.endswith(">")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _mask_value(key: str, value: str) -> str:
|
|
348
|
+
if _secret_like(key) or len(value) > 12:
|
|
349
|
+
return value[:2] + "***" + value[-2:] if len(value) > 4 else "***"
|
|
350
|
+
return repr(value)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _secret_like(key: str) -> bool:
|
|
354
|
+
lowered = key.lower()
|
|
355
|
+
return any(part in lowered for part in ("secret", "token", "password", "passwd", "api_key", "apikey", "key"))
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _similar(left: str, right: str) -> bool:
|
|
359
|
+
if left == right:
|
|
360
|
+
return False
|
|
361
|
+
if left.replace("_", "") == right.replace("_", ""):
|
|
362
|
+
return True
|
|
363
|
+
if _expand_tokens(left) == _expand_tokens(right):
|
|
364
|
+
return True
|
|
365
|
+
if _token_acronym_match(left, right) or _token_acronym_match(right, left):
|
|
366
|
+
return True
|
|
367
|
+
if left.endswith(right) or right.endswith(left):
|
|
368
|
+
return True
|
|
369
|
+
return _levenshtein(left, right) <= max(2, min(len(left), len(right)) // 4)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _expand_tokens(value: str) -> list[str]:
|
|
373
|
+
return [ABBREVIATIONS.get(part.lower(), part.lower()) for part in value.split("_")]
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _token_acronym_match(short: str, long: str) -> bool:
|
|
377
|
+
short_parts = short.split("_")
|
|
378
|
+
long_parts = long.split("_")
|
|
379
|
+
if len(short_parts) != len(long_parts):
|
|
380
|
+
return False
|
|
381
|
+
return all(
|
|
382
|
+
short_part == long_part or long_part.startswith(short_part)
|
|
383
|
+
for short_part, long_part in zip(short_parts, long_parts)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _levenshtein(left: str, right: str) -> int:
|
|
388
|
+
previous = list(range(len(right) + 1))
|
|
389
|
+
for i, left_char in enumerate(left, start=1):
|
|
390
|
+
current = [i]
|
|
391
|
+
for j, right_char in enumerate(right, start=1):
|
|
392
|
+
insert = current[j - 1] + 1
|
|
393
|
+
delete = previous[j] + 1
|
|
394
|
+
replace = previous[j - 1] + (left_char != right_char)
|
|
395
|
+
current.append(min(insert, delete, replace))
|
|
396
|
+
previous = current
|
|
397
|
+
return previous[-1]
|
envgap/cli.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from envgap import __version__
|
|
7
|
+
from envgap.checker import run_check
|
|
8
|
+
from envgap.reporters.json import render_json
|
|
9
|
+
from envgap.reporters.terminal import render_terminal
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(argv: list[str] | None = None) -> int:
|
|
13
|
+
parser = _build_parser()
|
|
14
|
+
args = parser.parse_args(argv)
|
|
15
|
+
|
|
16
|
+
if args.command == "check":
|
|
17
|
+
result = run_check(
|
|
18
|
+
Path(args.path),
|
|
19
|
+
env_file=args.env_file,
|
|
20
|
+
example_file=args.example_file,
|
|
21
|
+
include_shell=not args.no_shell,
|
|
22
|
+
)
|
|
23
|
+
strict = args.strict or args.ci
|
|
24
|
+
output = render_json(result) if args.format == "json" else render_terminal(result, strict=strict)
|
|
25
|
+
print(output)
|
|
26
|
+
if result.has_errors:
|
|
27
|
+
return 1
|
|
28
|
+
if strict and result.has_warnings:
|
|
29
|
+
return 1
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
parser.print_help()
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
37
|
+
parser = argparse.ArgumentParser(
|
|
38
|
+
prog="envgap",
|
|
39
|
+
description="Find the gaps in your Python environment config.",
|
|
40
|
+
epilog="Start with: envgap check",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
43
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
44
|
+
|
|
45
|
+
check = subparsers.add_parser(
|
|
46
|
+
"check",
|
|
47
|
+
help="diagnose .env drift and Python environment variable usage",
|
|
48
|
+
description="Compare shell env, .env, .env.example, and Python code to explain config drift.",
|
|
49
|
+
)
|
|
50
|
+
check.add_argument("path", nargs="?", default=".", help="project directory to inspect (default: current directory)")
|
|
51
|
+
check.add_argument("--env-file", default=".env", help="dotenv file to compare, relative to PATH (default: .env)")
|
|
52
|
+
check.add_argument(
|
|
53
|
+
"--example-file",
|
|
54
|
+
default=".env.example",
|
|
55
|
+
help="documented dotenv example file, relative to PATH (default: .env.example)",
|
|
56
|
+
)
|
|
57
|
+
check.add_argument("--no-shell", action="store_true", help="ignore the current shell environment for deterministic checks")
|
|
58
|
+
check.add_argument("--format", choices=["terminal", "json"], default="terminal", help="output format (default: terminal)")
|
|
59
|
+
check.add_argument("--json", action="store_const", const="json", dest="format", help="shortcut for --format json")
|
|
60
|
+
check.add_argument("--strict", action="store_true", help="fail on warnings as well as errors")
|
|
61
|
+
check.add_argument("--ci", action="store_true", help="CI-friendly alias for --strict")
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if __name__ == "__main__":
|
|
66
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from envgap.model import EnvFile, EnvVar
|
|
7
|
+
|
|
8
|
+
KEY_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_dotenv(path: Path) -> EnvFile:
|
|
12
|
+
env_file = EnvFile(path=path)
|
|
13
|
+
seen: dict[str, list[EnvVar]] = {}
|
|
14
|
+
|
|
15
|
+
if not path.exists():
|
|
16
|
+
return env_file
|
|
17
|
+
|
|
18
|
+
for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
19
|
+
parsed = _parse_line(raw_line)
|
|
20
|
+
if parsed is None:
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
key, value = parsed
|
|
24
|
+
if not KEY_RE.match(key):
|
|
25
|
+
env_file.parse_warnings.append(f"{path}:{line_number}: skipped invalid key {key!r}")
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
var = EnvVar(key=key, value=value, path=path, line=line_number, raw=raw_line)
|
|
29
|
+
env_file.vars.append(var)
|
|
30
|
+
seen.setdefault(key, []).append(var)
|
|
31
|
+
|
|
32
|
+
env_file.duplicates = {key: vars_ for key, vars_ in seen.items() if len(vars_) > 1}
|
|
33
|
+
return env_file
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_line(line: str) -> tuple[str, str] | None:
|
|
37
|
+
stripped = line.strip()
|
|
38
|
+
if not stripped or stripped.startswith("#"):
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
if stripped.startswith("export "):
|
|
42
|
+
stripped = stripped[len("export ") :].lstrip()
|
|
43
|
+
|
|
44
|
+
if "=" not in stripped:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
key, value = stripped.split("=", 1)
|
|
48
|
+
key = key.strip()
|
|
49
|
+
value = _strip_inline_comment(value.strip())
|
|
50
|
+
return key, _unquote(value)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _strip_inline_comment(value: str) -> str:
|
|
54
|
+
quote: str | None = None
|
|
55
|
+
escaped = False
|
|
56
|
+
for index, char in enumerate(value):
|
|
57
|
+
if escaped:
|
|
58
|
+
escaped = False
|
|
59
|
+
continue
|
|
60
|
+
if char == "\\":
|
|
61
|
+
escaped = True
|
|
62
|
+
continue
|
|
63
|
+
if char in {"'", '"'}:
|
|
64
|
+
quote = None if quote == char else char if quote is None else quote
|
|
65
|
+
continue
|
|
66
|
+
if char == "#" and quote is None and (index == 0 or value[index - 1].isspace()):
|
|
67
|
+
return value[:index].rstrip()
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _unquote(value: str) -> str:
|
|
72
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
73
|
+
return value[1:-1]
|
|
74
|
+
return value
|
|
75
|
+
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from envgap.model import CodeUsage
|
|
7
|
+
|
|
8
|
+
SKIP_DIRS = {
|
|
9
|
+
".git",
|
|
10
|
+
".hg",
|
|
11
|
+
".mypy_cache",
|
|
12
|
+
".pytest_cache",
|
|
13
|
+
".ruff_cache",
|
|
14
|
+
".tox",
|
|
15
|
+
".venv",
|
|
16
|
+
"__pycache__",
|
|
17
|
+
"build",
|
|
18
|
+
"dist",
|
|
19
|
+
"node_modules",
|
|
20
|
+
"venv",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def find_python_files(root: Path) -> list[Path]:
|
|
25
|
+
return sorted(
|
|
26
|
+
path
|
|
27
|
+
for path in root.rglob("*.py")
|
|
28
|
+
if not any(part in SKIP_DIRS for part in path.relative_to(root).parts)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def scan_python_env_usage(root: Path) -> list[CodeUsage]:
|
|
33
|
+
usages: list[CodeUsage] = []
|
|
34
|
+
for path in find_python_files(root):
|
|
35
|
+
try:
|
|
36
|
+
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
37
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
38
|
+
continue
|
|
39
|
+
visitor = _EnvVisitor(path)
|
|
40
|
+
visitor.visit(tree)
|
|
41
|
+
usages.extend(visitor.usages)
|
|
42
|
+
return sorted(usages, key=lambda usage: (str(usage.path), usage.line, usage.key))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class _EnvVisitor(ast.NodeVisitor):
|
|
46
|
+
def __init__(self, path: Path) -> None:
|
|
47
|
+
self.path = path
|
|
48
|
+
self.usages: list[CodeUsage] = []
|
|
49
|
+
|
|
50
|
+
def visit_Subscript(self, node: ast.Subscript) -> None:
|
|
51
|
+
if _is_os_environ(node.value):
|
|
52
|
+
key = _string_literal(node.slice)
|
|
53
|
+
if key:
|
|
54
|
+
self.usages.append(
|
|
55
|
+
CodeUsage(
|
|
56
|
+
key=key,
|
|
57
|
+
path=self.path,
|
|
58
|
+
line=node.lineno,
|
|
59
|
+
required=True,
|
|
60
|
+
source=f'os.environ["{key}"]',
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
self.generic_visit(node)
|
|
64
|
+
|
|
65
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
66
|
+
key = None
|
|
67
|
+
source = None
|
|
68
|
+
if _is_os_getenv(node.func):
|
|
69
|
+
key = _first_string_arg(node)
|
|
70
|
+
source = f'os.getenv("{key}")' if key else "os.getenv"
|
|
71
|
+
elif _is_os_environ_get(node.func):
|
|
72
|
+
key = _first_string_arg(node)
|
|
73
|
+
source = f'os.environ.get("{key}")' if key else "os.environ.get"
|
|
74
|
+
|
|
75
|
+
if key and source:
|
|
76
|
+
required = len(node.args) == 1 and not any(kw.arg == "default" for kw in node.keywords)
|
|
77
|
+
self.usages.append(
|
|
78
|
+
CodeUsage(
|
|
79
|
+
key=key,
|
|
80
|
+
path=self.path,
|
|
81
|
+
line=node.lineno,
|
|
82
|
+
required=required,
|
|
83
|
+
source=source,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
self.generic_visit(node)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_os_environ(node: ast.AST) -> bool:
|
|
90
|
+
return (
|
|
91
|
+
isinstance(node, ast.Attribute)
|
|
92
|
+
and node.attr == "environ"
|
|
93
|
+
and isinstance(node.value, ast.Name)
|
|
94
|
+
and node.value.id == "os"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_os_getenv(node: ast.AST) -> bool:
|
|
99
|
+
return (
|
|
100
|
+
isinstance(node, ast.Attribute)
|
|
101
|
+
and node.attr == "getenv"
|
|
102
|
+
and isinstance(node.value, ast.Name)
|
|
103
|
+
and node.value.id == "os"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_os_environ_get(node: ast.AST) -> bool:
|
|
108
|
+
return isinstance(node, ast.Attribute) and node.attr == "get" and _is_os_environ(node.value)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _first_string_arg(node: ast.Call) -> str | None:
|
|
112
|
+
if not node.args:
|
|
113
|
+
return None
|
|
114
|
+
return _string_literal(node.args[0])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _string_literal(node: ast.AST) -> str | None:
|
|
118
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
119
|
+
return node.value
|
|
120
|
+
return None
|
envgap/model/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from envgap.model.env_source import EnvFile, EnvVar
|
|
2
|
+
from envgap.model.expected_var import CodeUsage, ExpectedVar
|
|
3
|
+
from envgap.model.finding import Finding, Severity
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"CodeUsage",
|
|
7
|
+
"EnvFile",
|
|
8
|
+
"EnvVar",
|
|
9
|
+
"ExpectedVar",
|
|
10
|
+
"Finding",
|
|
11
|
+
"Severity",
|
|
12
|
+
]
|
|
13
|
+
|