envsleuth 0.1.0__tar.gz

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.
@@ -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,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,195 @@
1
+ # envsleuth
2
+
3
+ [![tests](https://github.com/k38f/envsleuth/actions/workflows/tests.yml/badge.svg)](https://github.com/k38f/envsleuth/actions/workflows/tests.yml)
4
+ [![pypi](https://img.shields.io/pypi/v/envsleuth.svg)](https://pypi.org/project/envsleuth/)
5
+ [![python](https://img.shields.io/pypi/pyversions/envsleuth.svg)](https://pypi.org/project/envsleuth/)
6
+ [![license](https://img.shields.io/pypi/l/envsleuth.svg)](LICENSE)
7
+
8
+ > 🕵️ 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.
9
+
10
+ No more shipping to prod and realising you forgot `STRIPE_API_KEY`.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install envsleuth
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```bash
21
+ # scan current directory, check against ./.env
22
+ envsleuth scan
23
+
24
+ # specific directory, specific env file
25
+ envsleuth scan --path ./src --env .env.production
26
+
27
+ # CI mode — exits 1 if anything is missing
28
+ envsleuth scan --strict
29
+
30
+ # generate a .env.example from your code
31
+ envsleuth generate
32
+
33
+ # machine-readable output
34
+ envsleuth scan --json
35
+ ```
36
+
37
+ ### Example output
38
+
39
+ ```
40
+ Found 6 variables in code
41
+ checking against .env
42
+
43
+ ⚠️ AWS_SECRET — not in .env but has default in code (probably ok)
44
+ ✅ DATABASE_URL
45
+ ✅ DEBUG
46
+ ❌ REDIS_URL — missing from .env
47
+ at src/app.py:7
48
+ ✅ SECRET_KEY
49
+ ❌ STRIPE_API_KEY — missing from .env
50
+ at src/app.py:6
51
+
52
+ ⚠️ 1 dynamic usage (variable name computed at runtime, can't check statically)
53
+ src/app.py:12 → getenv(name)
54
+
55
+ ℹ 1 variable in .env not referenced in code: UNUSED_VAR
56
+
57
+ 3 ok 1 with default 2 missing
58
+ ```
59
+
60
+ ## What it detects
61
+
62
+ Works with all three common patterns:
63
+
64
+ ```python
65
+ import os
66
+
67
+ a = os.getenv("A") # required — must be in .env
68
+ b = os.getenv("B", "fallback") # has default — warned but not required
69
+ c = os.environ["C"] # required (would raise KeyError without)
70
+ d = os.environ.get("D") # required
71
+ ```
72
+
73
+ Also handles aliased imports:
74
+
75
+ ```python
76
+ from os import getenv, environ
77
+ import os as sys_os
78
+
79
+ a = getenv("A")
80
+ b = environ["B"]
81
+ c = sys_os.getenv("C")
82
+ ```
83
+
84
+ 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.
85
+
86
+ ## `envsleuth generate`
87
+
88
+ 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:
89
+
90
+ ```bash
91
+ $ envsleuth generate
92
+ Wrote 6 variables to .env.example
93
+
94
+ $ cat .env.example
95
+ # Generated by envsleuth — edit this file before committing.
96
+ # Each variable below is used somewhere in your code.
97
+
98
+ # used at src/app.py:8
99
+ AWS_SECRET=default-value
100
+
101
+ # used at src/app.py:3
102
+ DATABASE_URL=
103
+
104
+ # used at src/app.py:5
105
+ DEBUG=false
106
+ ...
107
+ ```
108
+
109
+ Use `--force` to overwrite an existing file, `--output path/to/file` to write elsewhere.
110
+
111
+ ## `.envignore`
112
+
113
+ Exclude variables from the "missing" check with glob patterns — one per line:
114
+
115
+ ```
116
+ # .envignore
117
+ TEST_*
118
+ LEGACY_*
119
+ DEBUG_TOOL
120
+ ```
121
+
122
+ Great for vars that come from CI, Docker, or your shell rc files rather than the local `.env`.
123
+
124
+ ## CI usage
125
+
126
+ GitHub Actions:
127
+
128
+ ```yaml
129
+ - name: Check env vars
130
+ run: |
131
+ pip install envsleuth
132
+ envsleuth scan --env .env.example --strict
133
+ ```
134
+
135
+ pre-commit:
136
+
137
+ ```yaml
138
+ # .pre-commit-config.yaml
139
+ - repo: local
140
+ hooks:
141
+ - id: envsleuth
142
+ name: envsleuth
143
+ entry: envsleuth scan --strict
144
+ language: system
145
+ pass_filenames: false
146
+ ```
147
+
148
+ ## CLI reference
149
+
150
+ ### `envsleuth scan`
151
+
152
+ | Flag | Description |
153
+ | --- | --- |
154
+ | `--path`, `-p` | Directory or file to scan. Default: `.` |
155
+ | `--env` | Path to `.env` file. Default: `./.env` |
156
+ | `--envignore` | Path to `.envignore`. Default: `./.envignore` if present |
157
+ | `--strict` | Exit with code 1 if vars are missing |
158
+ | `--json` | JSON output for CI pipelines |
159
+ | `--no-color` | Disable ANSI colors (also honours `NO_COLOR` env var) |
160
+ | `--exclude DIR` | Extra directory name to skip. Can be repeated |
161
+ | `--ext .EXT` | Extra file extension to scan (e.g. `.pyi`). Can be repeated |
162
+ | `--verbose`, `-v` | Show usage locations for every variable |
163
+
164
+ ### `envsleuth generate`
165
+
166
+ | Flag | Description |
167
+ | --- | --- |
168
+ | `--path`, `-p` | Directory or file to scan. Default: `.` |
169
+ | `--output`, `-o` | Where to write. Default: `./.env.example` |
170
+ | `--force`, `-f` | Overwrite existing output file |
171
+ | `--exclude`, `--ext` | Same as in `scan` |
172
+
173
+ ## How it compares
174
+
175
+ | | envsleuth | [dotenv-linter](https://github.com/dotenv-linter/dotenv-linter) | [python-decouple](https://github.com/HBNetwork/python-decouple) |
176
+ | --- | --- | --- | --- |
177
+ | Scans your **code** for env var usages | ✅ | ❌ | ❌ |
178
+ | Lints the **.env file itself** | ❌ | ✅ | ❌ |
179
+ | Runtime config reader with casting | ❌ | ❌ | ✅ |
180
+ | Generates `.env.example` from code | ✅ | ❌ | ❌ |
181
+ | Language | Python | Rust | Python |
182
+
183
+ 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.
184
+
185
+ ## Dependencies
186
+
187
+ - [click](https://click.palletsprojects.com/) — CLI
188
+ - [python-dotenv](https://github.com/theskumar/python-dotenv) — `.env` parsing
189
+ - [flashbar](https://github.com/k38f/flashbar) — progress bar (a tiny zero-dep lib I wrote; envsleuth uses it when scanning 20+ files)
190
+
191
+ The scanner itself uses only the Python standard library (`ast`).
192
+
193
+ ## License
194
+
195
+ MIT
@@ -0,0 +1,3 @@
1
+ """envsleuth — static analyzer for environment variables in Python projects."""
2
+
3
+ __version__ = "0.1.0"
@@ -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