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 ADDED
@@ -0,0 +1,3 @@
1
+ """envsleuth — static analyzer for environment variables in Python projects."""
2
+
3
+ __version__ = "0.1.0"
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
+ [![tests](https://github.com/k38f/envsleuth/actions/workflows/tests.yml/badge.svg)](https://github.com/k38f/envsleuth/actions/workflows/tests.yml)
37
+ [![pypi](https://img.shields.io/pypi/v/envsleuth.svg)](https://pypi.org/project/envsleuth/)
38
+ [![python](https://img.shields.io/pypi/pyversions/envsleuth.svg)](https://pypi.org/project/envsleuth/)
39
+ [![license](https://img.shields.io/pypi/l/envsleuth.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ envsleuth = envsleuth.cli:cli
@@ -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