envsleuth 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.
- envsleuth/__init__.py +3 -0
- envsleuth/checker.py +160 -0
- envsleuth/cli.py +218 -0
- envsleuth/display.py +269 -0
- envsleuth/generator.py +100 -0
- envsleuth/scanner.py +316 -0
- envsleuth-0.1.0.dist-info/METADATA +228 -0
- envsleuth-0.1.0.dist-info/RECORD +12 -0
- envsleuth-0.1.0.dist-info/WHEEL +5 -0
- envsleuth-0.1.0.dist-info/entry_points.txt +2 -0
- envsleuth-0.1.0.dist-info/licenses/LICENSE +21 -0
- envsleuth-0.1.0.dist-info/top_level.txt +1 -0
envsleuth/__init__.py
ADDED
envsleuth/checker.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Compare scanned env vars against an actual .env file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from dotenv import dotenv_values
|
|
11
|
+
|
|
12
|
+
from envsleuth.scanner import EnvUsage, ScanResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_ENV_FILE = ".env"
|
|
16
|
+
DEFAULT_ENVIGNORE_FILE = ".envignore"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class VarReport:
|
|
21
|
+
"""Per-variable status after comparing scan results with .env."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
present_in_env: bool
|
|
25
|
+
has_default_in_code: bool
|
|
26
|
+
usages: List[EnvUsage] = field(default_factory=list)
|
|
27
|
+
ignored: bool = False
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def status(self) -> str:
|
|
31
|
+
"""One of: 'present', 'missing', 'default', 'ignored'."""
|
|
32
|
+
if self.ignored:
|
|
33
|
+
return "ignored"
|
|
34
|
+
if self.present_in_env:
|
|
35
|
+
return "present"
|
|
36
|
+
if self.has_default_in_code:
|
|
37
|
+
return "default"
|
|
38
|
+
return "missing"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CheckReport:
|
|
43
|
+
"""Full comparison report."""
|
|
44
|
+
|
|
45
|
+
variables: List[VarReport] = field(default_factory=list)
|
|
46
|
+
dynamic_usages: List[EnvUsage] = field(default_factory=list)
|
|
47
|
+
env_file: Optional[Path] = None
|
|
48
|
+
env_file_exists: bool = False
|
|
49
|
+
extra_in_env: List[str] = field(default_factory=list)
|
|
50
|
+
ignore_patterns: List[str] = field(default_factory=list)
|
|
51
|
+
errors: List[Tuple[Path, str]] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def missing(self) -> List[VarReport]:
|
|
55
|
+
return [v for v in self.variables if v.status == "missing"]
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def present(self) -> List[VarReport]:
|
|
59
|
+
return [v for v in self.variables if v.status == "present"]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def with_default(self) -> List[VarReport]:
|
|
63
|
+
return [v for v in self.variables if v.status == "default"]
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def ignored(self) -> List[VarReport]:
|
|
67
|
+
return [v for v in self.variables if v.status == "ignored"]
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def has_issues(self) -> bool:
|
|
71
|
+
"""True if anything requires user attention (missing vars or scan errors)."""
|
|
72
|
+
return bool(self.missing) or bool(self.errors)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# --------------------------------------------------------------------- helpers
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_env_file(path: Path) -> Dict[str, Optional[str]]:
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return {}
|
|
81
|
+
return dict(dotenv_values(path))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def load_ignore_patterns(path: Path) -> List[str]:
|
|
85
|
+
"""Read .envignore — one glob pattern per line. Blank lines and '#' are ignored."""
|
|
86
|
+
if not path.exists():
|
|
87
|
+
return []
|
|
88
|
+
patterns: List[str] = []
|
|
89
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
90
|
+
line = raw.strip()
|
|
91
|
+
if not line or line.startswith("#"):
|
|
92
|
+
continue
|
|
93
|
+
patterns.append(line)
|
|
94
|
+
return patterns
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _matches_any(name: str, patterns: List[str]) -> bool:
|
|
98
|
+
return any(fnmatch.fnmatchcase(name, p) for p in patterns)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# -------------------------------------------------------------------- main api
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check(
|
|
105
|
+
scan: ScanResult,
|
|
106
|
+
env_path: Path,
|
|
107
|
+
ignore_patterns: Optional[List[str]] = None,
|
|
108
|
+
) -> CheckReport:
|
|
109
|
+
"""Compare scan results against the given .env file and return a report."""
|
|
110
|
+
patterns = ignore_patterns or []
|
|
111
|
+
env_values = load_env_file(env_path)
|
|
112
|
+
env_keys = set(env_values.keys())
|
|
113
|
+
|
|
114
|
+
by_name: Dict[str, List[EnvUsage]] = {}
|
|
115
|
+
for u in scan.usages:
|
|
116
|
+
if u.name is None:
|
|
117
|
+
continue
|
|
118
|
+
by_name.setdefault(u.name, []).append(u)
|
|
119
|
+
|
|
120
|
+
variables: List[VarReport] = []
|
|
121
|
+
for name in sorted(by_name):
|
|
122
|
+
usages = by_name[name]
|
|
123
|
+
has_default = any(u.has_default for u in usages)
|
|
124
|
+
ignored = _matches_any(name, patterns)
|
|
125
|
+
variables.append(
|
|
126
|
+
VarReport(
|
|
127
|
+
name=name,
|
|
128
|
+
present_in_env=name in env_keys,
|
|
129
|
+
has_default_in_code=has_default,
|
|
130
|
+
usages=usages,
|
|
131
|
+
ignored=ignored,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
code_names = set(by_name.keys())
|
|
136
|
+
extra_in_env = sorted(env_keys - code_names)
|
|
137
|
+
|
|
138
|
+
return CheckReport(
|
|
139
|
+
variables=variables,
|
|
140
|
+
dynamic_usages=scan.dynamic_usages,
|
|
141
|
+
env_file=env_path,
|
|
142
|
+
env_file_exists=env_path.exists(),
|
|
143
|
+
extra_in_env=extra_in_env,
|
|
144
|
+
ignore_patterns=patterns,
|
|
145
|
+
errors=list(scan.errors),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def find_nearby_env_files(root: Path) -> List[Path]:
|
|
150
|
+
"""Look for .env.* files in `root` to help the user when .env is missing."""
|
|
151
|
+
if not root.is_dir():
|
|
152
|
+
return []
|
|
153
|
+
candidates: List[Path] = []
|
|
154
|
+
for p in sorted(root.iterdir()):
|
|
155
|
+
if not p.is_file():
|
|
156
|
+
continue
|
|
157
|
+
name = p.name
|
|
158
|
+
if name == ".env" or name.startswith(".env."):
|
|
159
|
+
candidates.append(p)
|
|
160
|
+
return candidates
|
envsleuth/cli.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""CLI entry point for envsleuth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, Set
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from envsleuth import __version__
|
|
12
|
+
from envsleuth.checker import (
|
|
13
|
+
DEFAULT_ENV_FILE,
|
|
14
|
+
DEFAULT_ENVIGNORE_FILE,
|
|
15
|
+
check,
|
|
16
|
+
find_nearby_env_files,
|
|
17
|
+
load_ignore_patterns,
|
|
18
|
+
)
|
|
19
|
+
from envsleuth.display import (
|
|
20
|
+
render_env_not_found_error,
|
|
21
|
+
render_report,
|
|
22
|
+
render_report_json,
|
|
23
|
+
should_use_color,
|
|
24
|
+
)
|
|
25
|
+
from envsleuth.generator import write_env_example
|
|
26
|
+
from envsleuth.scanner import scan_project
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# show flashbar only if the project is big enough that a blink of progress is useful
|
|
30
|
+
PROGRESS_THRESHOLD = 20
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group()
|
|
34
|
+
@click.version_option(version=__version__, prog_name="envsleuth")
|
|
35
|
+
def cli() -> None:
|
|
36
|
+
"""envsleuth — find env vars in your code and check them against .env."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------- scan
|
|
41
|
+
|
|
42
|
+
@cli.command()
|
|
43
|
+
@click.option(
|
|
44
|
+
"--path", "-p",
|
|
45
|
+
type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
|
|
46
|
+
default=".",
|
|
47
|
+
help="Directory (or file) to scan. Defaults to current directory.",
|
|
48
|
+
)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--env", "env_file",
|
|
51
|
+
type=click.Path(path_type=Path),
|
|
52
|
+
default=None,
|
|
53
|
+
help=f"Path to the .env file. Defaults to ./{DEFAULT_ENV_FILE}",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--envignore",
|
|
57
|
+
type=click.Path(path_type=Path),
|
|
58
|
+
default=None,
|
|
59
|
+
help=f"Path to .envignore. Defaults to ./{DEFAULT_ENVIGNORE_FILE} if present.",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--strict", is_flag=True,
|
|
63
|
+
help="Exit with code 1 if any vars are missing (for CI).",
|
|
64
|
+
)
|
|
65
|
+
@click.option(
|
|
66
|
+
"--json", "as_json", is_flag=True,
|
|
67
|
+
help="Emit machine-readable JSON instead of the human report.",
|
|
68
|
+
)
|
|
69
|
+
@click.option(
|
|
70
|
+
"--no-color", is_flag=True,
|
|
71
|
+
help="Disable ANSI colors. Also set NO_COLOR=1 in your env.",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--exclude", multiple=True,
|
|
75
|
+
help="Extra directory name to skip. Can be repeated.",
|
|
76
|
+
)
|
|
77
|
+
@click.option(
|
|
78
|
+
"--ext", multiple=True,
|
|
79
|
+
help="Extra file extension to scan (e.g. .pyi). Can be repeated.",
|
|
80
|
+
)
|
|
81
|
+
@click.option(
|
|
82
|
+
"--verbose", "-v", is_flag=True,
|
|
83
|
+
help="Show usage locations for every variable, not just missing ones.",
|
|
84
|
+
)
|
|
85
|
+
def scan(
|
|
86
|
+
path: Path,
|
|
87
|
+
env_file: Optional[Path],
|
|
88
|
+
envignore: Optional[Path],
|
|
89
|
+
strict: bool,
|
|
90
|
+
as_json: bool,
|
|
91
|
+
no_color: bool,
|
|
92
|
+
exclude: tuple,
|
|
93
|
+
ext: tuple,
|
|
94
|
+
verbose: bool,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Scan a project for env var usages and check against .env."""
|
|
97
|
+
|
|
98
|
+
# resolve paths up front so error messages look sensible
|
|
99
|
+
root = path.resolve()
|
|
100
|
+
if env_file is None:
|
|
101
|
+
env_file = Path.cwd() / DEFAULT_ENV_FILE
|
|
102
|
+
env_file = env_file.resolve() if env_file.exists() else env_file
|
|
103
|
+
|
|
104
|
+
if envignore is None:
|
|
105
|
+
candidate = Path.cwd() / DEFAULT_ENVIGNORE_FILE
|
|
106
|
+
envignore = candidate if candidate.exists() else None
|
|
107
|
+
|
|
108
|
+
use_color = should_use_color(force=False if no_color else None)
|
|
109
|
+
# --json means no color regardless
|
|
110
|
+
if as_json:
|
|
111
|
+
use_color = False
|
|
112
|
+
|
|
113
|
+
# env file missing — bail out with a helpful message (unless JSON mode where
|
|
114
|
+
# we still want to emit a report for CI to consume)
|
|
115
|
+
if not env_file.exists() and not as_json:
|
|
116
|
+
# look for .env* files in the same directory the user pointed at
|
|
117
|
+
search_root = env_file.parent if env_file.parent.exists() else Path.cwd()
|
|
118
|
+
nearby = find_nearby_env_files(search_root)
|
|
119
|
+
click.echo(render_env_not_found_error(env_file, nearby, use_color=use_color), err=True)
|
|
120
|
+
sys.exit(2)
|
|
121
|
+
|
|
122
|
+
# build the sets for scan_project
|
|
123
|
+
exts: Optional[Set[str]] = None
|
|
124
|
+
if ext:
|
|
125
|
+
# include default .py plus the user-supplied ones, normalise the dot
|
|
126
|
+
exts = {".py"} | {e if e.startswith(".") else f".{e}" for e in ext}
|
|
127
|
+
extra_excl: Optional[Set[str]] = set(exclude) if exclude else None
|
|
128
|
+
|
|
129
|
+
# flashbar progress, but only if the project is big enough to justify it
|
|
130
|
+
files_preview = _count_files(root, exts, extra_excl)
|
|
131
|
+
use_progress = (not as_json) and files_preview >= PROGRESS_THRESHOLD and sys.stdout.isatty()
|
|
132
|
+
|
|
133
|
+
if use_progress:
|
|
134
|
+
from flashbar import Bar # imported lazily so --json path stays lean
|
|
135
|
+
bar = Bar(files_preview, label="Scanning", show_eta=True, show_speed=True)
|
|
136
|
+
def _tick(_f: Path) -> None:
|
|
137
|
+
bar.update()
|
|
138
|
+
result = scan_project(root, extensions=exts, extra_excludes=extra_excl, on_file=_tick)
|
|
139
|
+
else:
|
|
140
|
+
result = scan_project(root, extensions=exts, extra_excludes=extra_excl)
|
|
141
|
+
|
|
142
|
+
# .envignore patterns
|
|
143
|
+
patterns = load_ignore_patterns(envignore) if envignore else []
|
|
144
|
+
|
|
145
|
+
report = check(result, env_file, ignore_patterns=patterns)
|
|
146
|
+
|
|
147
|
+
if as_json:
|
|
148
|
+
click.echo(render_report_json(report))
|
|
149
|
+
else:
|
|
150
|
+
click.echo(render_report(report, use_color=use_color, verbose=verbose))
|
|
151
|
+
|
|
152
|
+
if strict and report.has_issues:
|
|
153
|
+
sys.exit(1)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _count_files(root: Path, exts, excludes) -> int:
|
|
157
|
+
"""Pre-count files so flashbar has a real total. Cheap compared to parsing."""
|
|
158
|
+
# TODO: scan_project already walks twice effectively — merge these eventually
|
|
159
|
+
from envsleuth.scanner import iter_python_files
|
|
160
|
+
return len(iter_python_files(root, extensions=exts, extra_excludes=excludes))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------- generate
|
|
164
|
+
|
|
165
|
+
@cli.command()
|
|
166
|
+
@click.option(
|
|
167
|
+
"--path", "-p",
|
|
168
|
+
type=click.Path(exists=True, file_okay=True, dir_okay=True, path_type=Path),
|
|
169
|
+
default=".",
|
|
170
|
+
)
|
|
171
|
+
@click.option(
|
|
172
|
+
"--output", "-o",
|
|
173
|
+
type=click.Path(path_type=Path),
|
|
174
|
+
default=None,
|
|
175
|
+
help="Where to write the example file. Defaults to ./.env.example",
|
|
176
|
+
)
|
|
177
|
+
@click.option("--force", "-f", is_flag=True, help="Overwrite if output file exists.")
|
|
178
|
+
@click.option("--no-color", is_flag=True)
|
|
179
|
+
@click.option("--exclude", multiple=True)
|
|
180
|
+
@click.option("--ext", multiple=True)
|
|
181
|
+
def generate(
|
|
182
|
+
path: Path,
|
|
183
|
+
output: Optional[Path],
|
|
184
|
+
force: bool,
|
|
185
|
+
no_color: bool,
|
|
186
|
+
exclude: tuple,
|
|
187
|
+
ext: tuple,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Generate a .env.example file from scanned code."""
|
|
190
|
+
|
|
191
|
+
root = path.resolve()
|
|
192
|
+
if output is None:
|
|
193
|
+
output = Path.cwd() / ".env.example"
|
|
194
|
+
|
|
195
|
+
use_color = should_use_color(force=False if no_color else None)
|
|
196
|
+
|
|
197
|
+
exts: Optional[Set[str]] = None
|
|
198
|
+
if ext:
|
|
199
|
+
exts = {".py"} | {e if e.startswith(".") else f".{e}" for e in ext}
|
|
200
|
+
extra_excl: Optional[Set[str]] = set(exclude) if exclude else None
|
|
201
|
+
|
|
202
|
+
result = scan_project(root, extensions=exts, extra_excludes=extra_excl)
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
write_env_example(result, output, force=force)
|
|
206
|
+
except FileExistsError as exc:
|
|
207
|
+
click.echo(f"Error: {exc}", err=True)
|
|
208
|
+
sys.exit(2)
|
|
209
|
+
|
|
210
|
+
n = len(result.static_names)
|
|
211
|
+
msg = f"Wrote {n} variable{'s' if n != 1 else ''} to {output}"
|
|
212
|
+
if use_color:
|
|
213
|
+
msg = f"\033[32m✓\033[0m {msg}"
|
|
214
|
+
click.echo(msg)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
cli()
|
envsleuth/display.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Terminal output with ANSI colors. No rich dep — just escape codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import List, Optional, TextIO
|
|
10
|
+
|
|
11
|
+
from envsleuth.checker import CheckReport
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ANSI escape codes. Keeping these as plain constants — no fancy Color class,
|
|
15
|
+
# it's 5 colors, not a design system.
|
|
16
|
+
RESET = "\033[0m"
|
|
17
|
+
BOLD = "\033[1m"
|
|
18
|
+
DIM = "\033[2m"
|
|
19
|
+
RED = "\033[31m"
|
|
20
|
+
GREEN = "\033[32m"
|
|
21
|
+
YELLOW = "\033[33m"
|
|
22
|
+
BLUE = "\033[34m"
|
|
23
|
+
CYAN = "\033[36m"
|
|
24
|
+
GRAY = "\033[90m"
|
|
25
|
+
|
|
26
|
+
# unused for now but might use for --verbose later
|
|
27
|
+
MAX_USAGES_SHOWN = 5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def should_use_color(stream: Optional[TextIO] = None, force: Optional[bool] = None) -> bool:
|
|
31
|
+
"""Decide whether to emit ANSI codes.
|
|
32
|
+
|
|
33
|
+
force=True/False overrides detection. NO_COLOR env var disables colors.
|
|
34
|
+
See https://no-color.org
|
|
35
|
+
"""
|
|
36
|
+
if force is not None:
|
|
37
|
+
return force
|
|
38
|
+
if os.environ.get("NO_COLOR"):
|
|
39
|
+
return False
|
|
40
|
+
s = stream or sys.stdout
|
|
41
|
+
# not all streams have isatty (e.g. some test runners)
|
|
42
|
+
try:
|
|
43
|
+
return s.isatty()
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Styler:
|
|
49
|
+
"""Wraps a bool — either returns text with ANSI codes or untouched."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, enabled: bool):
|
|
52
|
+
self.enabled = enabled
|
|
53
|
+
|
|
54
|
+
def _wrap(self, code: str, text: str) -> str:
|
|
55
|
+
if not self.enabled:
|
|
56
|
+
return text
|
|
57
|
+
return f"{code}{text}{RESET}"
|
|
58
|
+
|
|
59
|
+
def red(self, t: str) -> str: return self._wrap(RED, t)
|
|
60
|
+
def green(self, t: str) -> str: return self._wrap(GREEN, t)
|
|
61
|
+
def yellow(self, t: str) -> str: return self._wrap(YELLOW, t)
|
|
62
|
+
def blue(self, t: str) -> str: return self._wrap(BLUE, t)
|
|
63
|
+
def cyan(self, t: str) -> str: return self._wrap(CYAN, t)
|
|
64
|
+
def gray(self, t: str) -> str: return self._wrap(GRAY, t)
|
|
65
|
+
def bold(self, t: str) -> str: return self._wrap(BOLD, t)
|
|
66
|
+
def dim(self, t: str) -> str: return self._wrap(DIM, t)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------- text output
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render_report(report: CheckReport, use_color: bool = True, verbose: bool = False) -> str:
|
|
73
|
+
"""Build a human-readable report as a single string."""
|
|
74
|
+
s = Styler(use_color)
|
|
75
|
+
lines: List[str] = []
|
|
76
|
+
|
|
77
|
+
# header
|
|
78
|
+
total = len(report.variables)
|
|
79
|
+
lines.append(s.bold(f"Found {total} variable{'s' if total != 1 else ''} in code"))
|
|
80
|
+
if report.env_file is not None:
|
|
81
|
+
env_label = f"checking against {report.env_file}"
|
|
82
|
+
if not report.env_file_exists:
|
|
83
|
+
env_label += s.red(" (not found)")
|
|
84
|
+
lines.append(s.dim(env_label))
|
|
85
|
+
lines.append("")
|
|
86
|
+
|
|
87
|
+
# per-var list
|
|
88
|
+
for var in report.variables:
|
|
89
|
+
if var.status == "present":
|
|
90
|
+
icon = s.green("✅")
|
|
91
|
+
line = f"{icon} {var.name}"
|
|
92
|
+
if verbose and var.usages:
|
|
93
|
+
line += s.dim(f" ({len(var.usages)} usage{'s' if len(var.usages) != 1 else ''})")
|
|
94
|
+
elif var.status == "missing":
|
|
95
|
+
icon = s.red("❌")
|
|
96
|
+
line = f"{icon} {s.bold(var.name)} {s.dim('— missing from')} {report.env_file}"
|
|
97
|
+
elif var.status == "default":
|
|
98
|
+
icon = s.yellow("⚠️ ")
|
|
99
|
+
line = (
|
|
100
|
+
f"{icon} {var.name} "
|
|
101
|
+
f"{s.dim('— not in .env but has default in code (probably ok)')}"
|
|
102
|
+
)
|
|
103
|
+
elif var.status == "ignored":
|
|
104
|
+
icon = s.gray("·")
|
|
105
|
+
line = s.gray(f"{icon} {var.name} (ignored by .envignore)")
|
|
106
|
+
else:
|
|
107
|
+
# shouldn't happen but don't crash
|
|
108
|
+
line = f"? {var.name}"
|
|
109
|
+
|
|
110
|
+
lines.append(line)
|
|
111
|
+
|
|
112
|
+
# show where it's used when verbose, or always for missing (helpful context)
|
|
113
|
+
if verbose or var.status == "missing":
|
|
114
|
+
for u in var.usages[:MAX_USAGES_SHOWN]:
|
|
115
|
+
try:
|
|
116
|
+
rel = u.file.relative_to(Path.cwd())
|
|
117
|
+
except ValueError:
|
|
118
|
+
rel = u.file
|
|
119
|
+
lines.append(s.dim(f" at {rel}:{u.line}"))
|
|
120
|
+
if len(var.usages) > MAX_USAGES_SHOWN:
|
|
121
|
+
extra = len(var.usages) - MAX_USAGES_SHOWN
|
|
122
|
+
lines.append(s.dim(f" ... and {extra} more"))
|
|
123
|
+
|
|
124
|
+
# dynamic warnings
|
|
125
|
+
if report.dynamic_usages:
|
|
126
|
+
lines.append("")
|
|
127
|
+
lines.append(s.yellow(
|
|
128
|
+
f"⚠️ {len(report.dynamic_usages)} dynamic usage"
|
|
129
|
+
f"{'s' if len(report.dynamic_usages) != 1 else ''} "
|
|
130
|
+
f"(variable name computed at runtime, can't check statically)"
|
|
131
|
+
))
|
|
132
|
+
for u in report.dynamic_usages:
|
|
133
|
+
try:
|
|
134
|
+
rel = u.file.relative_to(Path.cwd())
|
|
135
|
+
except ValueError:
|
|
136
|
+
rel = u.file
|
|
137
|
+
expr = u.raw_expr or "?"
|
|
138
|
+
lines.append(s.dim(f" {rel}:{u.line} → {u.call_type}({expr})"))
|
|
139
|
+
|
|
140
|
+
# extras in .env (not fatal, just informational)
|
|
141
|
+
if report.extra_in_env:
|
|
142
|
+
lines.append("")
|
|
143
|
+
lines.append(s.dim(
|
|
144
|
+
f"ℹ {len(report.extra_in_env)} variable"
|
|
145
|
+
f"{'s' if len(report.extra_in_env) != 1 else ''} "
|
|
146
|
+
f"in .env not referenced in code: {', '.join(report.extra_in_env)}"
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
# scan errors — these are real problems, surface them at the end
|
|
150
|
+
if report.errors:
|
|
151
|
+
lines.append("")
|
|
152
|
+
lines.append(s.red(f"Scan errors ({len(report.errors)}):"))
|
|
153
|
+
for path, msg in report.errors:
|
|
154
|
+
try:
|
|
155
|
+
rel = path.relative_to(Path.cwd())
|
|
156
|
+
except ValueError:
|
|
157
|
+
rel = path
|
|
158
|
+
lines.append(s.red(f" {rel}: {msg}"))
|
|
159
|
+
|
|
160
|
+
# summary footer
|
|
161
|
+
lines.append("")
|
|
162
|
+
summary = _render_summary(report, s)
|
|
163
|
+
lines.append(summary)
|
|
164
|
+
|
|
165
|
+
return "\n".join(lines)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _render_summary(report: CheckReport, s: Styler) -> str:
|
|
169
|
+
n_missing = len(report.missing)
|
|
170
|
+
n_present = len(report.present)
|
|
171
|
+
n_default = len(report.with_default)
|
|
172
|
+
n_ignored = len(report.ignored)
|
|
173
|
+
|
|
174
|
+
parts = []
|
|
175
|
+
if n_present:
|
|
176
|
+
parts.append(s.green(f"{n_present} ok"))
|
|
177
|
+
if n_default:
|
|
178
|
+
parts.append(s.yellow(f"{n_default} with default"))
|
|
179
|
+
if n_missing:
|
|
180
|
+
parts.append(s.red(f"{n_missing} missing"))
|
|
181
|
+
if n_ignored:
|
|
182
|
+
parts.append(s.gray(f"{n_ignored} ignored"))
|
|
183
|
+
|
|
184
|
+
if not parts:
|
|
185
|
+
return s.dim("No env vars found in code.")
|
|
186
|
+
return " ".join(parts)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# -------------------------------------------------------------------- errors
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def render_env_not_found_error(
|
|
193
|
+
env_path: Path,
|
|
194
|
+
nearby: List[Path],
|
|
195
|
+
use_color: bool = True,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Nice error message when the user's .env file doesn't exist."""
|
|
198
|
+
s = Styler(use_color)
|
|
199
|
+
lines = [
|
|
200
|
+
s.red(s.bold(f"Error: {env_path} not found.")),
|
|
201
|
+
"",
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
# filter out the one we were looking for
|
|
205
|
+
others = [p for p in nearby if p != env_path]
|
|
206
|
+
|
|
207
|
+
if others:
|
|
208
|
+
lines.append("Found other env-like files in this directory:")
|
|
209
|
+
for p in others:
|
|
210
|
+
lines.append(s.cyan(f" {p.name}"))
|
|
211
|
+
lines.append("")
|
|
212
|
+
lines.append(
|
|
213
|
+
f"Try: envsleuth scan --env {others[0].name}"
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
lines.append(s.dim("No .env files found in this directory."))
|
|
217
|
+
lines.append("")
|
|
218
|
+
lines.append("If your .env lives elsewhere, use:")
|
|
219
|
+
lines.append(f" {s.cyan('envsleuth scan --env path/to/.env')}")
|
|
220
|
+
|
|
221
|
+
return "\n".join(lines)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------- json out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def render_report_json(report: CheckReport) -> str:
|
|
228
|
+
"""Machine-readable output for CI pipelines."""
|
|
229
|
+
def _usage(u) -> dict:
|
|
230
|
+
return {
|
|
231
|
+
"file": str(u.file),
|
|
232
|
+
"line": u.line,
|
|
233
|
+
"call_type": u.call_type,
|
|
234
|
+
"has_default": u.has_default,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
data = {
|
|
238
|
+
"env_file": str(report.env_file) if report.env_file else None,
|
|
239
|
+
"env_file_exists": report.env_file_exists,
|
|
240
|
+
"summary": {
|
|
241
|
+
"total": len(report.variables),
|
|
242
|
+
"present": len(report.present),
|
|
243
|
+
"missing": len(report.missing),
|
|
244
|
+
"with_default": len(report.with_default),
|
|
245
|
+
"ignored": len(report.ignored),
|
|
246
|
+
"dynamic": len(report.dynamic_usages),
|
|
247
|
+
"errors": len(report.errors),
|
|
248
|
+
},
|
|
249
|
+
"variables": [
|
|
250
|
+
{
|
|
251
|
+
"name": v.name,
|
|
252
|
+
"status": v.status,
|
|
253
|
+
"usages": [_usage(u) for u in v.usages],
|
|
254
|
+
}
|
|
255
|
+
for v in report.variables
|
|
256
|
+
],
|
|
257
|
+
"dynamic_usages": [
|
|
258
|
+
{
|
|
259
|
+
"file": str(u.file),
|
|
260
|
+
"line": u.line,
|
|
261
|
+
"expression": u.raw_expr,
|
|
262
|
+
"call_type": u.call_type,
|
|
263
|
+
}
|
|
264
|
+
for u in report.dynamic_usages
|
|
265
|
+
],
|
|
266
|
+
"extra_in_env": report.extra_in_env,
|
|
267
|
+
"errors": [{"file": str(p), "error": m} for p, m in report.errors],
|
|
268
|
+
}
|
|
269
|
+
return json.dumps(data, indent=2)
|
envsleuth/generator.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Generate .env.example from scan results.
|
|
2
|
+
|
|
3
|
+
Takes a ScanResult and writes a template file with:
|
|
4
|
+
- one line per variable
|
|
5
|
+
- comment pointing to where it's used in code
|
|
6
|
+
- the default value from code (if any) as the placeholder
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from envsleuth.scanner import EnvUsage, ScanResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
HEADER = """\
|
|
19
|
+
# Generated by envsleuth — edit this file before committing.
|
|
20
|
+
# Each variable below is used somewhere in your code.
|
|
21
|
+
# Replace the empty values with real defaults/examples, but don't commit secrets.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _first_literal_default(usages: List[EnvUsage]) -> Optional[str]:
|
|
26
|
+
"""Try to recover a literal default value from one of the usage sites.
|
|
27
|
+
|
|
28
|
+
We already know which usages have has_default=True; this re-parses the line
|
|
29
|
+
to pull out the second arg's constant value. It's a bit of a hack but works
|
|
30
|
+
well enough for the common case.
|
|
31
|
+
"""
|
|
32
|
+
# TODO: properly thread the default node through from scanner instead of
|
|
33
|
+
# re-reading the file here. Works for now.
|
|
34
|
+
for u in usages:
|
|
35
|
+
if not u.has_default:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
source = u.file.read_text(encoding="utf-8")
|
|
39
|
+
except OSError:
|
|
40
|
+
continue
|
|
41
|
+
try:
|
|
42
|
+
tree = ast.parse(source)
|
|
43
|
+
except SyntaxError:
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
for node in ast.walk(tree):
|
|
47
|
+
if not isinstance(node, ast.Call):
|
|
48
|
+
continue
|
|
49
|
+
if node.lineno != u.line:
|
|
50
|
+
continue
|
|
51
|
+
# getenv(name, default) or environ.get(name, default)
|
|
52
|
+
if len(node.args) >= 2:
|
|
53
|
+
default = node.args[1]
|
|
54
|
+
if isinstance(default, ast.Constant):
|
|
55
|
+
if default.value is None:
|
|
56
|
+
return ""
|
|
57
|
+
return str(default.value)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_env_example(scan: ScanResult) -> str:
|
|
62
|
+
"""Produce the text content for a .env.example file."""
|
|
63
|
+
lines: List[str] = [HEADER]
|
|
64
|
+
|
|
65
|
+
# group usages by name
|
|
66
|
+
grouped: dict = {}
|
|
67
|
+
for u in scan.usages:
|
|
68
|
+
if u.name is None:
|
|
69
|
+
continue
|
|
70
|
+
grouped.setdefault(u.name, []).append(u)
|
|
71
|
+
|
|
72
|
+
if not grouped:
|
|
73
|
+
lines.append("# No environment variables found in code.")
|
|
74
|
+
return "\n".join(lines) + "\n"
|
|
75
|
+
|
|
76
|
+
for name in sorted(grouped):
|
|
77
|
+
usages = grouped[name]
|
|
78
|
+
|
|
79
|
+
# show up to 3 locations
|
|
80
|
+
locations = [f"{u.file}:{u.line}" for u in usages[:3]]
|
|
81
|
+
if len(usages) > 3:
|
|
82
|
+
locations.append(f"...and {len(usages) - 3} more")
|
|
83
|
+
lines.append(f"# used at {', '.join(locations)}")
|
|
84
|
+
|
|
85
|
+
default = _first_literal_default(usages)
|
|
86
|
+
if default is not None and default != "":
|
|
87
|
+
lines.append(f"{name}={default}")
|
|
88
|
+
else:
|
|
89
|
+
lines.append(f"{name}=")
|
|
90
|
+
lines.append("") # blank between groups, easier to scan
|
|
91
|
+
|
|
92
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_env_example(scan: ScanResult, path: Path, force: bool = False) -> None:
|
|
96
|
+
"""Write a .env.example to `path`. Raises FileExistsError if it exists and !force."""
|
|
97
|
+
if path.exists() and not force:
|
|
98
|
+
raise FileExistsError(f"{path} already exists (use --force to overwrite)")
|
|
99
|
+
content = build_env_example(scan)
|
|
100
|
+
path.write_text(content, encoding="utf-8")
|
envsleuth/scanner.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""AST-based scanner for environment variable usages in Python code.
|
|
2
|
+
|
|
3
|
+
Detects three patterns:
|
|
4
|
+
os.getenv("VAR") -> static usage
|
|
5
|
+
os.getenv("VAR", "default") -> static usage with default
|
|
6
|
+
os.environ["VAR"] -> static usage
|
|
7
|
+
os.environ.get("VAR") -> static usage
|
|
8
|
+
os.environ.get("VAR", "def") -> static usage with default
|
|
9
|
+
os.getenv(var_name) -> dynamic usage (can't resolve)
|
|
10
|
+
os.getenv(f"PREFIX_{x}") -> dynamic usage (can't resolve)
|
|
11
|
+
|
|
12
|
+
The scanner also tracks aliased imports:
|
|
13
|
+
from os import getenv; getenv("X") -> detected
|
|
14
|
+
from os import environ; environ["X"] -> detected
|
|
15
|
+
import os as operating_system -> detected
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable, List, Optional, Set, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class EnvUsage:
|
|
28
|
+
"""A single occurrence of an env var lookup in source code."""
|
|
29
|
+
|
|
30
|
+
name: Optional[str]
|
|
31
|
+
"""Variable name if it could be resolved statically, else None."""
|
|
32
|
+
|
|
33
|
+
file: Path
|
|
34
|
+
"""File where the usage was found."""
|
|
35
|
+
|
|
36
|
+
line: int
|
|
37
|
+
"""1-based line number."""
|
|
38
|
+
|
|
39
|
+
has_default: bool = False
|
|
40
|
+
"""True if a default value was provided (e.g. os.getenv('X', 'fallback'))."""
|
|
41
|
+
|
|
42
|
+
call_type: str = "getenv"
|
|
43
|
+
"""One of: 'getenv', 'environ_subscript', 'environ_get'."""
|
|
44
|
+
|
|
45
|
+
raw_expr: Optional[str] = None
|
|
46
|
+
"""For dynamic usages: text of the unresolved expression (for diagnostics)."""
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_dynamic(self) -> bool:
|
|
50
|
+
return self.name is None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ScanResult:
|
|
55
|
+
"""Aggregated result of scanning one or more files."""
|
|
56
|
+
|
|
57
|
+
usages: List[EnvUsage] = field(default_factory=list)
|
|
58
|
+
scanned_files: List[Path] = field(default_factory=list)
|
|
59
|
+
errors: List[Tuple[Path, str]] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def static_names(self) -> Set[str]:
|
|
63
|
+
"""Unique set of statically-resolved variable names."""
|
|
64
|
+
return {u.name for u in self.usages if u.name is not None}
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def dynamic_usages(self) -> List[EnvUsage]:
|
|
68
|
+
return [u for u in self.usages if u.is_dynamic]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def names_with_defaults(self) -> Set[str]:
|
|
72
|
+
"""Names that have at least one usage with a default value provided."""
|
|
73
|
+
return {u.name for u in self.usages if u.name is not None and u.has_default}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ScanError(Exception):
|
|
77
|
+
"""Raised when a single file cannot be scanned."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _EnvVisitor(ast.NodeVisitor):
|
|
81
|
+
"""AST visitor that collects env var usages from a single module."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, file_path: Path) -> None:
|
|
84
|
+
self.file_path = file_path
|
|
85
|
+
self.usages: List[EnvUsage] = []
|
|
86
|
+
|
|
87
|
+
# Track import aliases. We need to recognize calls whether the user wrote
|
|
88
|
+
# `os.getenv(...)`, `import os as foo; foo.getenv(...)`, or
|
|
89
|
+
# `from os import getenv; getenv(...)`.
|
|
90
|
+
self._os_aliases: Set[str] = set()
|
|
91
|
+
self._getenv_aliases: Set[str] = set()
|
|
92
|
+
self._environ_aliases: Set[str] = set()
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------ imports
|
|
95
|
+
|
|
96
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
97
|
+
for alias in node.names:
|
|
98
|
+
if alias.name == "os":
|
|
99
|
+
self._os_aliases.add(alias.asname or "os")
|
|
100
|
+
self.generic_visit(node)
|
|
101
|
+
|
|
102
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
103
|
+
if node.module == "os":
|
|
104
|
+
for alias in node.names:
|
|
105
|
+
bound = alias.asname or alias.name
|
|
106
|
+
if alias.name == "getenv":
|
|
107
|
+
self._getenv_aliases.add(bound)
|
|
108
|
+
elif alias.name == "environ":
|
|
109
|
+
self._environ_aliases.add(bound)
|
|
110
|
+
self.generic_visit(node)
|
|
111
|
+
|
|
112
|
+
# ------------------------------------------------------------------- calls
|
|
113
|
+
|
|
114
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
115
|
+
# order matters here — environ.get is also a Call so we check getenv first
|
|
116
|
+
if self._is_getenv_call(node):
|
|
117
|
+
self._record_call(node, call_type="getenv")
|
|
118
|
+
elif self._is_environ_get_call(node):
|
|
119
|
+
self._record_call(node, call_type="environ_get")
|
|
120
|
+
self.generic_visit(node)
|
|
121
|
+
|
|
122
|
+
def visit_Subscript(self, node: ast.Subscript) -> None:
|
|
123
|
+
if self._is_environ_subscript(node):
|
|
124
|
+
self._record_subscript(node)
|
|
125
|
+
self.generic_visit(node)
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------- matchers
|
|
128
|
+
|
|
129
|
+
def _is_getenv_call(self, node: ast.Call) -> bool:
|
|
130
|
+
func = node.func
|
|
131
|
+
# os.getenv(...)
|
|
132
|
+
if isinstance(func, ast.Attribute) and func.attr == "getenv":
|
|
133
|
+
return isinstance(func.value, ast.Name) and func.value.id in self._os_aliases
|
|
134
|
+
# getenv(...) via `from os import getenv`
|
|
135
|
+
if isinstance(func, ast.Name):
|
|
136
|
+
return func.id in self._getenv_aliases
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def _is_environ_get_call(self, node: ast.Call) -> bool:
|
|
140
|
+
func = node.func
|
|
141
|
+
if not isinstance(func, ast.Attribute) or func.attr != "get":
|
|
142
|
+
return False
|
|
143
|
+
inner = func.value
|
|
144
|
+
# os.environ.get(...)
|
|
145
|
+
if isinstance(inner, ast.Attribute) and inner.attr == "environ":
|
|
146
|
+
return isinstance(inner.value, ast.Name) and inner.value.id in self._os_aliases
|
|
147
|
+
# environ.get(...) via `from os import environ`
|
|
148
|
+
if isinstance(inner, ast.Name) and inner.id in self._environ_aliases:
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def _is_environ_subscript(self, node: ast.Subscript) -> bool:
|
|
153
|
+
value = node.value
|
|
154
|
+
# os.environ["X"]
|
|
155
|
+
if isinstance(value, ast.Attribute) and value.attr == "environ":
|
|
156
|
+
return isinstance(value.value, ast.Name) and value.value.id in self._os_aliases
|
|
157
|
+
# environ["X"] via `from os import environ`
|
|
158
|
+
if isinstance(value, ast.Name) and value.id in self._environ_aliases:
|
|
159
|
+
return True
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
# ----------------------------------------------------------------- recording
|
|
163
|
+
|
|
164
|
+
def _record_call(self, node: ast.Call, call_type: str) -> None:
|
|
165
|
+
if not node.args:
|
|
166
|
+
return
|
|
167
|
+
name_arg = node.args[0]
|
|
168
|
+
has_default = len(node.args) >= 2 or bool(node.keywords)
|
|
169
|
+
name = _extract_string(name_arg)
|
|
170
|
+
self.usages.append(
|
|
171
|
+
EnvUsage(
|
|
172
|
+
name=name,
|
|
173
|
+
file=self.file_path,
|
|
174
|
+
line=node.lineno,
|
|
175
|
+
has_default=has_default,
|
|
176
|
+
call_type=call_type,
|
|
177
|
+
raw_expr=None if name is not None else _unparse(name_arg),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _record_subscript(self, node: ast.Subscript) -> None:
|
|
182
|
+
key_node = _subscript_key(node)
|
|
183
|
+
name = _extract_string(key_node) if key_node is not None else None
|
|
184
|
+
raw = None if name is not None else (_unparse(key_node) if key_node is not None else "?")
|
|
185
|
+
self.usages.append(
|
|
186
|
+
EnvUsage(
|
|
187
|
+
name=name,
|
|
188
|
+
file=self.file_path,
|
|
189
|
+
line=node.lineno,
|
|
190
|
+
has_default=False,
|
|
191
|
+
call_type="environ_subscript",
|
|
192
|
+
raw_expr=raw,
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ------------------------------------------------------------------ helpers
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _extract_string(node: Optional[ast.AST]) -> Optional[str]:
|
|
201
|
+
"""Return the string value if node is a constant string literal, else None."""
|
|
202
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
203
|
+
return node.value
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _subscript_key(node: ast.Subscript) -> Optional[ast.AST]:
|
|
208
|
+
"""Get the index node from a Subscript, handling py3.8 ast.Index wrapper."""
|
|
209
|
+
key = node.slice
|
|
210
|
+
# On Python 3.8, Subscript.slice is an ast.Index wrapping the actual node.
|
|
211
|
+
Index = getattr(ast, "Index", None)
|
|
212
|
+
if Index is not None and isinstance(key, Index):
|
|
213
|
+
return key.value # type: ignore[attr-defined]
|
|
214
|
+
return key
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _unparse(node: Optional[ast.AST]) -> str:
|
|
218
|
+
"""Best-effort textual representation of an AST node for diagnostics."""
|
|
219
|
+
if node is None:
|
|
220
|
+
return "?"
|
|
221
|
+
unparse = getattr(ast, "unparse", None)
|
|
222
|
+
if unparse is not None:
|
|
223
|
+
try:
|
|
224
|
+
return unparse(node)
|
|
225
|
+
except Exception:
|
|
226
|
+
pass
|
|
227
|
+
return f"<expr at line {getattr(node, 'lineno', '?')}>"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ----------------------------------------------------------------- public api
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def scan_file(path: Path) -> List[EnvUsage]:
|
|
234
|
+
try:
|
|
235
|
+
source = path.read_text(encoding="utf-8")
|
|
236
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
237
|
+
raise ScanError(f"could not read {path}: {exc}") from exc
|
|
238
|
+
try:
|
|
239
|
+
tree = ast.parse(source, filename=str(path))
|
|
240
|
+
except SyntaxError as exc:
|
|
241
|
+
# might break on files with encoding declaratrions in weird places, haven't hit it yet
|
|
242
|
+
raise ScanError(f"syntax error in {path}: {exc}") from exc
|
|
243
|
+
visitor = _EnvVisitor(path)
|
|
244
|
+
visitor.visit(tree)
|
|
245
|
+
return visitor.usages
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Default directories to skip when walking a project tree.
|
|
249
|
+
DEFAULT_EXCLUDES = frozenset({
|
|
250
|
+
"venv", ".venv", "env",
|
|
251
|
+
"__pycache__", ".git", ".hg", ".svn",
|
|
252
|
+
"node_modules",
|
|
253
|
+
".tox", ".nox",
|
|
254
|
+
"build", "dist",
|
|
255
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
256
|
+
".eggs",
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
DEFAULT_EXTENSIONS = frozenset({".py"})
|
|
260
|
+
|
|
261
|
+
# TODO: warn on files above this size, probably not worth scanning test fixtures
|
|
262
|
+
MAX_FILE_SIZE = 2 * 1024 * 1024
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def iter_python_files(
|
|
266
|
+
root: Path,
|
|
267
|
+
extensions: Optional[Set[str]] = None,
|
|
268
|
+
extra_excludes: Optional[Set[str]] = None,
|
|
269
|
+
) -> List[Path]:
|
|
270
|
+
"""Walk `root` and yield all files matching the given extensions.
|
|
271
|
+
|
|
272
|
+
Directories named in DEFAULT_EXCLUDES (plus any in extra_excludes) are skipped.
|
|
273
|
+
"""
|
|
274
|
+
exts = extensions or DEFAULT_EXTENSIONS
|
|
275
|
+
excludes = set(DEFAULT_EXCLUDES)
|
|
276
|
+
if extra_excludes:
|
|
277
|
+
excludes |= set(extra_excludes)
|
|
278
|
+
|
|
279
|
+
files: List[Path] = []
|
|
280
|
+
if root.is_file():
|
|
281
|
+
if root.suffix in exts:
|
|
282
|
+
files.append(root)
|
|
283
|
+
return files
|
|
284
|
+
|
|
285
|
+
for path in root.rglob("*"):
|
|
286
|
+
if any(part in excludes for part in path.parts):
|
|
287
|
+
continue
|
|
288
|
+
if path.is_file() and path.suffix in exts:
|
|
289
|
+
files.append(path)
|
|
290
|
+
return sorted(files)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def scan_project(
|
|
294
|
+
root: Path,
|
|
295
|
+
extensions: Optional[Set[str]] = None,
|
|
296
|
+
extra_excludes: Optional[Set[str]] = None,
|
|
297
|
+
on_file: Optional[Callable[[Path], None]] = None,
|
|
298
|
+
) -> ScanResult:
|
|
299
|
+
"""Scan every Python file under `root` and return aggregated results.
|
|
300
|
+
|
|
301
|
+
`on_file` is an optional callback invoked after each file is processed
|
|
302
|
+
(useful for wiring up a progress bar from the CLI layer).
|
|
303
|
+
"""
|
|
304
|
+
result = ScanResult()
|
|
305
|
+
files = iter_python_files(root, extensions=extensions, extra_excludes=extra_excludes)
|
|
306
|
+
result.scanned_files = files
|
|
307
|
+
|
|
308
|
+
for f in files:
|
|
309
|
+
try:
|
|
310
|
+
result.usages.extend(scan_file(f))
|
|
311
|
+
except ScanError as exc:
|
|
312
|
+
result.errors.append((f, str(exc)))
|
|
313
|
+
if on_file is not None:
|
|
314
|
+
on_file(f)
|
|
315
|
+
|
|
316
|
+
return result
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: envsleuth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Detective for env vars in Python code. Parses your source with AST, finds every os.getenv/os.environ usage, and tells you what's missing from your .env file.
|
|
5
|
+
Author: k38f
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/k38f/envsleuth
|
|
8
|
+
Project-URL: Repository, https://github.com/k38f/envsleuth
|
|
9
|
+
Project-URL: Issues, https://github.com/k38f/envsleuth/issues
|
|
10
|
+
Keywords: env,dotenv,cli,static-analysis,ast,environment-variables
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: click>=8.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0
|
|
28
|
+
Requires-Dist: flashbar>=1.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# envsleuth
|
|
35
|
+
|
|
36
|
+
[](https://github.com/k38f/envsleuth/actions/workflows/tests.yml)
|
|
37
|
+
[](https://pypi.org/project/envsleuth/)
|
|
38
|
+
[](https://pypi.org/project/envsleuth/)
|
|
39
|
+
[](LICENSE)
|
|
40
|
+
|
|
41
|
+
> 🕵️ The detective for env vars in Python code. Parses your source with AST, finds every `os.getenv()` / `os.environ[]` / `os.environ.get()`, and tells you what's missing from your `.env` file.
|
|
42
|
+
|
|
43
|
+
No more shipping to prod and realising you forgot `STRIPE_API_KEY`.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install envsleuth
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# scan current directory, check against ./.env
|
|
55
|
+
envsleuth scan
|
|
56
|
+
|
|
57
|
+
# specific directory, specific env file
|
|
58
|
+
envsleuth scan --path ./src --env .env.production
|
|
59
|
+
|
|
60
|
+
# CI mode — exits 1 if anything is missing
|
|
61
|
+
envsleuth scan --strict
|
|
62
|
+
|
|
63
|
+
# generate a .env.example from your code
|
|
64
|
+
envsleuth generate
|
|
65
|
+
|
|
66
|
+
# machine-readable output
|
|
67
|
+
envsleuth scan --json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Example output
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
Found 6 variables in code
|
|
74
|
+
checking against .env
|
|
75
|
+
|
|
76
|
+
⚠️ AWS_SECRET — not in .env but has default in code (probably ok)
|
|
77
|
+
✅ DATABASE_URL
|
|
78
|
+
✅ DEBUG
|
|
79
|
+
❌ REDIS_URL — missing from .env
|
|
80
|
+
at src/app.py:7
|
|
81
|
+
✅ SECRET_KEY
|
|
82
|
+
❌ STRIPE_API_KEY — missing from .env
|
|
83
|
+
at src/app.py:6
|
|
84
|
+
|
|
85
|
+
⚠️ 1 dynamic usage (variable name computed at runtime, can't check statically)
|
|
86
|
+
src/app.py:12 → getenv(name)
|
|
87
|
+
|
|
88
|
+
ℹ 1 variable in .env not referenced in code: UNUSED_VAR
|
|
89
|
+
|
|
90
|
+
3 ok 1 with default 2 missing
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## What it detects
|
|
94
|
+
|
|
95
|
+
Works with all three common patterns:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import os
|
|
99
|
+
|
|
100
|
+
a = os.getenv("A") # required — must be in .env
|
|
101
|
+
b = os.getenv("B", "fallback") # has default — warned but not required
|
|
102
|
+
c = os.environ["C"] # required (would raise KeyError without)
|
|
103
|
+
d = os.environ.get("D") # required
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Also handles aliased imports:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from os import getenv, environ
|
|
110
|
+
import os as sys_os
|
|
111
|
+
|
|
112
|
+
a = getenv("A")
|
|
113
|
+
b = environ["B"]
|
|
114
|
+
c = sys_os.getenv("C")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Variables with names computed at runtime (e.g. `os.getenv(f"PREFIX_{x}")`) can't be checked statically — they're reported in a separate warning section so you know they exist.
|
|
118
|
+
|
|
119
|
+
## `envsleuth generate`
|
|
120
|
+
|
|
121
|
+
Scans your code and writes a `.env.example` with every variable found, a comment pointing at where it's used, and the default value from code if there is one:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
$ envsleuth generate
|
|
125
|
+
Wrote 6 variables to .env.example
|
|
126
|
+
|
|
127
|
+
$ cat .env.example
|
|
128
|
+
# Generated by envsleuth — edit this file before committing.
|
|
129
|
+
# Each variable below is used somewhere in your code.
|
|
130
|
+
|
|
131
|
+
# used at src/app.py:8
|
|
132
|
+
AWS_SECRET=default-value
|
|
133
|
+
|
|
134
|
+
# used at src/app.py:3
|
|
135
|
+
DATABASE_URL=
|
|
136
|
+
|
|
137
|
+
# used at src/app.py:5
|
|
138
|
+
DEBUG=false
|
|
139
|
+
...
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Use `--force` to overwrite an existing file, `--output path/to/file` to write elsewhere.
|
|
143
|
+
|
|
144
|
+
## `.envignore`
|
|
145
|
+
|
|
146
|
+
Exclude variables from the "missing" check with glob patterns — one per line:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
# .envignore
|
|
150
|
+
TEST_*
|
|
151
|
+
LEGACY_*
|
|
152
|
+
DEBUG_TOOL
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Great for vars that come from CI, Docker, or your shell rc files rather than the local `.env`.
|
|
156
|
+
|
|
157
|
+
## CI usage
|
|
158
|
+
|
|
159
|
+
GitHub Actions:
|
|
160
|
+
|
|
161
|
+
```yaml
|
|
162
|
+
- name: Check env vars
|
|
163
|
+
run: |
|
|
164
|
+
pip install envsleuth
|
|
165
|
+
envsleuth scan --env .env.example --strict
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
pre-commit:
|
|
169
|
+
|
|
170
|
+
```yaml
|
|
171
|
+
# .pre-commit-config.yaml
|
|
172
|
+
- repo: local
|
|
173
|
+
hooks:
|
|
174
|
+
- id: envsleuth
|
|
175
|
+
name: envsleuth
|
|
176
|
+
entry: envsleuth scan --strict
|
|
177
|
+
language: system
|
|
178
|
+
pass_filenames: false
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## CLI reference
|
|
182
|
+
|
|
183
|
+
### `envsleuth scan`
|
|
184
|
+
|
|
185
|
+
| Flag | Description |
|
|
186
|
+
| --- | --- |
|
|
187
|
+
| `--path`, `-p` | Directory or file to scan. Default: `.` |
|
|
188
|
+
| `--env` | Path to `.env` file. Default: `./.env` |
|
|
189
|
+
| `--envignore` | Path to `.envignore`. Default: `./.envignore` if present |
|
|
190
|
+
| `--strict` | Exit with code 1 if vars are missing |
|
|
191
|
+
| `--json` | JSON output for CI pipelines |
|
|
192
|
+
| `--no-color` | Disable ANSI colors (also honours `NO_COLOR` env var) |
|
|
193
|
+
| `--exclude DIR` | Extra directory name to skip. Can be repeated |
|
|
194
|
+
| `--ext .EXT` | Extra file extension to scan (e.g. `.pyi`). Can be repeated |
|
|
195
|
+
| `--verbose`, `-v` | Show usage locations for every variable |
|
|
196
|
+
|
|
197
|
+
### `envsleuth generate`
|
|
198
|
+
|
|
199
|
+
| Flag | Description |
|
|
200
|
+
| --- | --- |
|
|
201
|
+
| `--path`, `-p` | Directory or file to scan. Default: `.` |
|
|
202
|
+
| `--output`, `-o` | Where to write. Default: `./.env.example` |
|
|
203
|
+
| `--force`, `-f` | Overwrite existing output file |
|
|
204
|
+
| `--exclude`, `--ext` | Same as in `scan` |
|
|
205
|
+
|
|
206
|
+
## How it compares
|
|
207
|
+
|
|
208
|
+
| | envsleuth | [dotenv-linter](https://github.com/dotenv-linter/dotenv-linter) | [python-decouple](https://github.com/HBNetwork/python-decouple) |
|
|
209
|
+
| --- | --- | --- | --- |
|
|
210
|
+
| Scans your **code** for env var usages | ✅ | ❌ | ❌ |
|
|
211
|
+
| Lints the **.env file itself** | ❌ | ✅ | ❌ |
|
|
212
|
+
| Runtime config reader with casting | ❌ | ❌ | ✅ |
|
|
213
|
+
| Generates `.env.example` from code | ✅ | ❌ | ❌ |
|
|
214
|
+
| Language | Python | Rust | Python |
|
|
215
|
+
|
|
216
|
+
envsleuth is the only tool here that understands your **source code**. The others either look at your `.env` file in isolation, or read env vars at runtime.
|
|
217
|
+
|
|
218
|
+
## Dependencies
|
|
219
|
+
|
|
220
|
+
- [click](https://click.palletsprojects.com/) — CLI
|
|
221
|
+
- [python-dotenv](https://github.com/theskumar/python-dotenv) — `.env` parsing
|
|
222
|
+
- [flashbar](https://github.com/k38f/flashbar) — progress bar (a tiny zero-dep lib I wrote; envsleuth uses it when scanning 20+ files)
|
|
223
|
+
|
|
224
|
+
The scanner itself uses only the Python standard library (`ast`).
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
envsleuth/__init__.py,sha256=XE7sPOSampeyt5JAj9PQgoTAiMyL1cgvTu59S81FChY,105
|
|
2
|
+
envsleuth/checker.py,sha256=2Zmibiy8oHw4HZusVYzBz11jVFmoTWxW4E_3nthOcj0,4685
|
|
3
|
+
envsleuth/cli.py,sha256=bUnS0fgUDzvXnEDWKNWjkiIvcSvfyQGP7u1dqmkRdlw,6854
|
|
4
|
+
envsleuth/display.py,sha256=T3L3V8qRp5CtYx5-kjONofEvOQ8dcY6d8q_kIEDgr9M,8810
|
|
5
|
+
envsleuth/generator.py,sha256=dCi5d9Mn8XG0WAKIpjTt2bsPKwzS0LArl9ArUjQKm6E,3321
|
|
6
|
+
envsleuth/scanner.py,sha256=RXWGSD2zs_SR1AcSzq769hkTQgrCXZ27M3D3btiIQew,11014
|
|
7
|
+
envsleuth-0.1.0.dist-info/licenses/LICENSE,sha256=PO0Zof1q5ED5CtdGIxF_vvgGbiwAlAImqcfYi4K9FtE,1061
|
|
8
|
+
envsleuth-0.1.0.dist-info/METADATA,sha256=_XQ7SWxVW8mPCoy34aPvULm_Ao-WXbfsJzcqwhd_RGY,7029
|
|
9
|
+
envsleuth-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
envsleuth-0.1.0.dist-info/entry_points.txt,sha256=fNmL1gFMWfJkfmHiZqUi7aVkExc9u2jM-aFl9N2Jf1E,48
|
|
11
|
+
envsleuth-0.1.0.dist-info/top_level.txt,sha256=vfdfF2IAQUKlrLFQDeTcu4hHHVSi2MOS9l_CzBuvXsg,10
|
|
12
|
+
envsleuth-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 k38f
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
envsleuth
|