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 ADDED
@@ -0,0 +1,3 @@
1
+ """envgap finds gaps in Python environment config."""
2
+
3
+ __version__ = "0.1.0"
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,2 @@
1
+ """Extract expected and actual environment variables from project files."""
2
+
@@ -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
@@ -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
+