env-example-sync-cli 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.
env_example_sync.py ADDED
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Keep .env.example in sync with the environment variables code actually uses.
3
+
4
+ One job only: scan source files for environment-variable references, compare
5
+ them with the keys in .env.example, and report the drift:
6
+
7
+ - missing: used in code, but not documented in .env.example
8
+ - extra: documented in .env.example, but not used in code
9
+
10
+ Exit codes: 0 = in sync, 1 = drift found, 2 = usage error (argparse).
11
+
12
+ With --fix, variables that are missing from .env.example are appended to it
13
+ as ``NAME=`` (no value is ever invented). Extra variables are never removed
14
+ automatically: deleting a documented variable is a human decision.
15
+
16
+ Standard library only. Nothing is sent anywhere; everything runs locally.
17
+
18
+ Supported references (text patterns, not an AST -- see README limits):
19
+ - Python: os.environ["X"], os.environ.get("X"), os.getenv("X")
20
+ - JavaScript/TypeScript: process.env.X, process.env["X"],
21
+ import.meta.env.X, import.meta.env["X"]
22
+ - Go: os.Getenv("X")
23
+ - PHP: getenv("X"), $_ENV["X"], $_SERVER["X"]
24
+ - Shell: ${X}, $X
25
+ """
26
+
27
+ import argparse
28
+ import json
29
+ import re
30
+ import sys
31
+ from pathlib import Path
32
+
33
+ VAR = r"[A-Za-z_][A-Za-z0-9_]*"
34
+ # Shell locals are lowercase by convention, so shell patterns only match
35
+ # uppercase names -- otherwise every $count in a script would be flagged.
36
+ SHELL_VAR = r"[A-Z_][A-Z0-9_]*"
37
+
38
+ PATTERNS = [
39
+ # Python
40
+ re.compile(r"os\.environ\[\s*['\"](" + VAR + r")['\"]\s*\]"),
41
+ re.compile(r"os\.environ\.get\(\s*['\"](" + VAR + r")['\"]"),
42
+ re.compile(r"os\.getenv\(\s*['\"](" + VAR + r")['\"]"),
43
+ # JavaScript / TypeScript
44
+ re.compile(r"process\.env\.(" + VAR + r")"),
45
+ re.compile(r"process\.env\[\s*['\"](" + VAR + r")['\"]\s*\]"),
46
+ re.compile(r"import\.meta\.env\.(" + VAR + r")"),
47
+ re.compile(r"import\.meta\.env\[\s*['\"](" + VAR + r")['\"]\s*\]"),
48
+ # Go
49
+ re.compile(r"os\.Getenv\(\s*\"(" + VAR + r")\""),
50
+ # PHP
51
+ re.compile(r"getenv\(\s*['\"](" + VAR + r")['\"]"),
52
+ re.compile(r"\$_(?:ENV|SERVER)\[\s*['\"](" + VAR + r")['\"]\s*\]"),
53
+ # Shell: ${VAR} and $VAR (braced form first, so ${VAR} is not double-counted)
54
+ re.compile(r"\$\{(" + SHELL_VAR + r")"),
55
+ # Exclude PHP's $_ENV / $_SERVER, which have their own pattern above.
56
+ re.compile(r"\$(?!\{)(?!_(?:ENV|SERVER)\b)(" + SHELL_VAR + r")"),
57
+ ]
58
+
59
+ EXTENSIONS = {".py", ".js", ".jsx", ".ts", ".tsx", ".sh", ".bash", ".zsh", ".go", ".php"}
60
+
61
+ EXCLUDED_DIRS = {
62
+ ".git", "node_modules", "__pycache__", ".venv", "venv", "env",
63
+ "dist", "build", ".tox", ".pytest_cache", "coverage", ".idea", ".vscode",
64
+ }
65
+
66
+ ENV_FILENAMES = {".env", ".env.example", ".env.local", ".env.sample"}
67
+
68
+
69
+ def parse_example(text):
70
+ """Return the set of variable names documented in .env.example text."""
71
+ keys = set()
72
+ for raw_line in text.splitlines():
73
+ line = raw_line.strip()
74
+ if not line or line.startswith("#"):
75
+ continue
76
+ if line.startswith("export "):
77
+ line = line[len("export "):].strip()
78
+ if "=" not in line:
79
+ continue
80
+ key = line.split("=", 1)[0].strip()
81
+ if re.fullmatch(VAR, key):
82
+ keys.add(key)
83
+ return keys
84
+
85
+
86
+ def scan_text(text):
87
+ """Return the set of variable names referenced in one source file."""
88
+ used = set()
89
+ for pattern in PATTERNS:
90
+ used.update(pattern.findall(text))
91
+ return used
92
+
93
+
94
+ def iter_source_files(root):
95
+ """Yield source files under root, skipping excluded dirs and env files."""
96
+ for path in sorted(root.rglob("*")):
97
+ if not path.is_file():
98
+ continue
99
+ if any(part in EXCLUDED_DIRS for part in path.relative_to(root).parts[:-1]):
100
+ continue
101
+ if path.name in ENV_FILENAMES:
102
+ continue
103
+ if path.suffix in EXTENSIONS:
104
+ yield path
105
+
106
+
107
+ def scan_directory(root):
108
+ """Return (used_vars, files_checked) for the source tree under root."""
109
+ used = set()
110
+ files_checked = 0
111
+ for path in iter_source_files(root):
112
+ try:
113
+ text = path.read_text(encoding="utf-8", errors="ignore")
114
+ except OSError:
115
+ continue
116
+ files_checked += 1
117
+ used.update(scan_text(text))
118
+ return used, files_checked
119
+
120
+
121
+ def check(root, example_path, ignored):
122
+ """Run one drift check. Returns a dict with sorted lists and a verdict."""
123
+ used, files_checked = scan_directory(root)
124
+ used -= set(ignored)
125
+ try:
126
+ documented = parse_example(example_path.read_text(encoding="utf-8"))
127
+ except OSError:
128
+ documented = set()
129
+ missing = sorted(used - documented)
130
+ extra = sorted(documented - used)
131
+ return {
132
+ "root": str(root),
133
+ "example": str(example_path),
134
+ "files_checked": files_checked,
135
+ "used": sorted(used),
136
+ "documented": sorted(documented),
137
+ "missing": missing,
138
+ "extra": extra,
139
+ "in_sync": not missing and not extra,
140
+ }
141
+
142
+
143
+ def render_text(result):
144
+ """Render a drift check as human-readable text."""
145
+ lines = [
146
+ "Checked: {} file(s)".format(result["files_checked"]),
147
+ "Used in code: {}".format(len(result["used"])),
148
+ "Documented in .env.example: {}".format(len(result["documented"])),
149
+ ]
150
+ if result["missing"]:
151
+ lines.append("Missing from .env.example (used but not documented):")
152
+ lines.extend(" - {}".format(name) for name in result["missing"])
153
+ if result["extra"]:
154
+ lines.append("Extra in .env.example (documented but not used):")
155
+ lines.extend(" - {}".format(name) for name in result["extra"])
156
+ lines.append("Result: {}".format("IN SYNC" if result["in_sync"] else "DRIFT"))
157
+ return "\n".join(lines)
158
+
159
+
160
+ def apply_fix(result, example_path):
161
+ """Append missing variables to .env.example as NAME= lines. Returns names."""
162
+ missing = result["missing"]
163
+ if not missing:
164
+ return []
165
+ with example_path.open("a", encoding="utf-8") as handle:
166
+ if example_path.stat().st_size > 0:
167
+ with example_path.open("rb") as probe:
168
+ probe.seek(-1, 2)
169
+ if probe.read(1) != b"\n":
170
+ handle.write("\n")
171
+ handle.write("".join("{}=\n".format(name) for name in missing))
172
+ return missing
173
+
174
+
175
+ def main(argv=None):
176
+ parser = argparse.ArgumentParser(
177
+ prog="env-example-sync",
178
+ description="Report drift between code's env-var use and .env.example.",
179
+ )
180
+ parser.add_argument("path", nargs="?", default=".",
181
+ help="project directory to scan (default: current directory)")
182
+ parser.add_argument("--example", default=".env.example",
183
+ help="path to .env.example (default: .env.example)")
184
+ parser.add_argument("--ignore", default="",
185
+ help="comma-separated variable names to ignore")
186
+ parser.add_argument("--format", choices=["text", "json"], default="text",
187
+ help="output format (default: text)")
188
+ parser.add_argument("--fix", action="store_true",
189
+ help="append missing variables to .env.example as NAME=")
190
+ args = parser.parse_args(argv)
191
+
192
+ root = Path(args.path)
193
+ if not root.is_dir():
194
+ parser.error("not a directory: {}".format(root))
195
+ example_path = Path(args.example)
196
+ ignored = [name.strip() for name in args.ignore.split(",") if name.strip()]
197
+
198
+ result = check(root, example_path, ignored)
199
+
200
+ if args.fix and result["missing"]:
201
+ fixed = apply_fix(result, example_path)
202
+ result = check(root, example_path, ignored)
203
+ if args.format == "text":
204
+ print("Added to {}: {}".format(example_path, ", ".join(fixed)))
205
+
206
+ if args.format == "json":
207
+ print(json.dumps(result, indent=2, sort_keys=True))
208
+ else:
209
+ print(render_text(result))
210
+ return 0 if result["in_sync"] else 1
211
+
212
+
213
+ if __name__ == "__main__":
214
+ sys.exit(main())
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: env-example-sync-cli
3
+ Version: 0.1.0
4
+ Summary: Scan code for environment variables, compare with `.env.example`, and fail CI when they drift.
5
+ Author: Haider Ali
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/haiderstudio/env-example-sync-cli
8
+ Project-URL: Repository, https://github.com/haiderstudio/env-example-sync-cli
9
+ Project-URL: Issues, https://github.com/haiderstudio/env-example-sync-cli/issues
10
+ Keywords: env,dotenv,environment-variables,cli,developer-tools
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # env-example-sync-cli
23
+
24
+ Scan code for environment variables, compare with `.env.example`, and fail CI when they drift.
25
+
26
+ [![CI](https://github.com/haiderstudio/env-example-sync-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/haiderstudio/env-example-sync-cli/actions/workflows/ci.yml) [![Licence: MIT](https://img.shields.io/badge/Licence-MIT-yellow.svg)](LICENSE)
27
+
28
+ ## What it does
29
+
30
+ Every project has a moment where someone adds `os.environ["NEW_KEY"]` to code
31
+ and forgets `.env.example`, and the next developer's setup breaks silently.
32
+ `env-example-sync` catches that before it ships:
33
+
34
+ - **Missing** — used in code, but not documented in `.env.example`.
35
+ - **Extra** — documented in `.env.example`, but no longer used in code.
36
+ - **Exit code 1 on drift, 0 when in sync** — so it works as a CI gate.
37
+ - **`--fix`** appends missing variables to `.env.example` as `NAME=` — no value
38
+ is ever invented, and extra variables are never deleted automatically.
39
+
40
+ It scans Python, JavaScript/TypeScript, Go, PHP, and shell files, and skips
41
+ `node_modules`, `.git`, virtualenvs, and `.env*` files themselves.
42
+
43
+ ## What it does NOT do
44
+
45
+ - It does not read, print, or upload any real secret value. It only ever
46
+ reports variable **names**.
47
+ - It does not load a real `.env` file, and it never touches one.
48
+ - It does not delete variables from `.env.example` — removing a documented
49
+ variable is a human decision, not an automated one.
50
+ - It does not parse code as an AST. It matches text patterns, so a variable
51
+ named only in a comment still counts as used. That is deliberate: it errs
52
+ toward keeping a variable documented rather than deleting it.
53
+ - Shell scripts only match uppercase names (`$HOME` counts, `$home` does not),
54
+ because lowercase shell words are locals by convention, not env vars.
55
+ - It sends nothing anywhere. Everything runs locally, Python standard
56
+ library only.
57
+
58
+ ## Install
59
+
60
+ No install step and no dependencies — Python 3.10+ is enough:
61
+
62
+ ```sh
63
+ git clone https://github.com/haiderstudio/env-example-sync-cli.git
64
+ cd env-example-sync-cli
65
+ python3 src/env_example_sync.py --help
66
+ ```
67
+
68
+ ## Usage
69
+
70
+ From your project root, where `.env.example` lives:
71
+
72
+ ```sh
73
+ python3 src/env_example_sync.py .
74
+ ```
75
+
76
+ Real output on the bundled sample project (see `examples/sample-project`,
77
+ where `TIMEOUT` is used but undocumented and `OLD_VAR` is documented but
78
+ unused):
79
+
80
+ ```text
81
+ Checked: 1 file(s)
82
+ Used in code: 3
83
+ Documented in .env.example: 3
84
+ Missing from .env.example (used but not documented):
85
+ - TIMEOUT
86
+ Extra in .env.example (documented but not used):
87
+ - OLD_VAR
88
+ Result: DRIFT
89
+ ```
90
+
91
+ That run exits with code 1 — exactly what a CI gate wants.
92
+
93
+ Other useful forms:
94
+
95
+ ```sh
96
+ # Machine-readable output for another tool to consume
97
+ python3 src/env_example_sync.py . --format json
98
+
99
+ # Ignore variables that are set by the platform, not by the project
100
+ python3 src/env_example_sync.py . --ignore PATH,HOME
101
+
102
+ # Append only the missing variables (as NAME=), then re-check
103
+ python3 src/env_example_sync.py . --fix
104
+
105
+ # Scan a different project, with its example file elsewhere
106
+ python3 src/env_example_sync.py /path/to/project --example /path/to/project/.env.example
107
+ ```
108
+
109
+ ## Examples
110
+
111
+ **Example 1 — a clean project passes.** A project whose code uses `API_KEY`
112
+ and whose `.env.example` lists exactly `API_KEY` prints `Result: IN SYNC`
113
+ and exits 0.
114
+
115
+ **Example 2 — the CI gate.** Add this to a workflow and a pull request that
116
+ adds a new env var without documenting it fails the build until
117
+ `.env.example` is updated (or `--fix` is run and the result committed):
118
+
119
+ ```yaml
120
+ - run: python3 src/env_example_sync.py .
121
+ ```
122
+
123
+ **Example 3 — the `--fix` edge case.** If a project has both a missing and
124
+ an extra variable, `--fix` appends the missing one but the run still exits
125
+ 1, because the extra variable still needs a human decision. Nothing is
126
+ silently deleted.
127
+
128
+ ## How it is tested
129
+
130
+ Standard library `unittest` only — 15 tests covering example parsing,
131
+ every language pattern, missing/extra classification, exit codes, JSON
132
+ output, `--fix`, `--ignore`, and excluded directories:
133
+
134
+ ```sh
135
+ python3 tests/test_env_example_sync.py
136
+ ```
137
+
138
+ All 15 pass on the files in this repo. The README's sample output above is
139
+ the tool's real output on `examples/sample-project`, not a mock.
140
+
141
+ ## Related tools (and how this one differs)
142
+
143
+ Similar drift checkers exist, mostly for JavaScript projects: `dotenv-audit`
144
+ (Node, scans JS/TS and can auto-fill `.env.example` with generated
145
+ placeholder values) and `dotdrift` (Node, compares `.env` with
146
+ `.env.example`). This tool is the Python-stdlib alternative: it needs no
147
+ Node or npm, covers Python, JS/TS, Go, PHP, and shell in one pass, never
148
+ reads a real `.env` file, and `--fix` appends empty `NAME=` lines only —
149
+ it never invents a placeholder value that could be mistaken for a real one.
150
+
151
+ ## Contributing
152
+
153
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Small, focused improvements are
154
+ welcome; never weaken the safety rules (no secret values, no automatic
155
+ deletion, no invented values).
156
+
157
+ ## Licence
158
+
159
+ MIT — see [LICENSE](LICENSE). This is original work, written for this repo;
160
+ the drift-checking idea is a common developer workflow, not copied code.
@@ -0,0 +1,7 @@
1
+ env_example_sync.py,sha256=xYK5PUjRlYUaTHETku4iAGyINSHHo5ghVKOA_ZMX0XQ,7838
2
+ env_example_sync_cli-0.1.0.dist-info/licenses/LICENSE,sha256=mTjYlQM7htiV7thgBkBwT0woURqRpTJG1eWK1Logfl8,1067
3
+ env_example_sync_cli-0.1.0.dist-info/METADATA,sha256=n5RPeKbDqLD9yZMcuTiWm7FGKm3bxuUWoF2UAtfRJqY,6039
4
+ env_example_sync_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ env_example_sync_cli-0.1.0.dist-info/entry_points.txt,sha256=2PlQfypLvtfKJ5aKcPos2tPxxUSkiKsn9MCcQZAB0-s,59
6
+ env_example_sync_cli-0.1.0.dist-info/top_level.txt,sha256=31pYp35qH__w1LRsr4E8qEU7g-4nMgOzOXdi6UqHFi4,17
7
+ env_example_sync_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ env-example-sync = env_example_sync:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haider Ali
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
+ env_example_sync