env-example-sync-cli 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.
- env_example_sync_cli-0.1.0/LICENSE +21 -0
- env_example_sync_cli-0.1.0/PKG-INFO +160 -0
- env_example_sync_cli-0.1.0/README.md +139 -0
- env_example_sync_cli-0.1.0/pyproject.toml +31 -0
- env_example_sync_cli-0.1.0/setup.cfg +4 -0
- env_example_sync_cli-0.1.0/src/env_example_sync.py +214 -0
- env_example_sync_cli-0.1.0/src/env_example_sync_cli.egg-info/PKG-INFO +160 -0
- env_example_sync_cli-0.1.0/src/env_example_sync_cli.egg-info/SOURCES.txt +10 -0
- env_example_sync_cli-0.1.0/src/env_example_sync_cli.egg-info/dependency_links.txt +1 -0
- env_example_sync_cli-0.1.0/src/env_example_sync_cli.egg-info/entry_points.txt +2 -0
- env_example_sync_cli-0.1.0/src/env_example_sync_cli.egg-info/top_level.txt +1 -0
- env_example_sync_cli-0.1.0/tests/test_env_example_sync.py +186 -0
|
@@ -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,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
|
+
[](https://github.com/haiderstudio/env-example-sync-cli/actions/workflows/ci.yml) [](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,139 @@
|
|
|
1
|
+
# env-example-sync-cli
|
|
2
|
+
|
|
3
|
+
Scan code for environment variables, compare with `.env.example`, and fail CI when they drift.
|
|
4
|
+
|
|
5
|
+
[](https://github.com/haiderstudio/env-example-sync-cli/actions/workflows/ci.yml) [](LICENSE)
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
Every project has a moment where someone adds `os.environ["NEW_KEY"]` to code
|
|
10
|
+
and forgets `.env.example`, and the next developer's setup breaks silently.
|
|
11
|
+
`env-example-sync` catches that before it ships:
|
|
12
|
+
|
|
13
|
+
- **Missing** — used in code, but not documented in `.env.example`.
|
|
14
|
+
- **Extra** — documented in `.env.example`, but no longer used in code.
|
|
15
|
+
- **Exit code 1 on drift, 0 when in sync** — so it works as a CI gate.
|
|
16
|
+
- **`--fix`** appends missing variables to `.env.example` as `NAME=` — no value
|
|
17
|
+
is ever invented, and extra variables are never deleted automatically.
|
|
18
|
+
|
|
19
|
+
It scans Python, JavaScript/TypeScript, Go, PHP, and shell files, and skips
|
|
20
|
+
`node_modules`, `.git`, virtualenvs, and `.env*` files themselves.
|
|
21
|
+
|
|
22
|
+
## What it does NOT do
|
|
23
|
+
|
|
24
|
+
- It does not read, print, or upload any real secret value. It only ever
|
|
25
|
+
reports variable **names**.
|
|
26
|
+
- It does not load a real `.env` file, and it never touches one.
|
|
27
|
+
- It does not delete variables from `.env.example` — removing a documented
|
|
28
|
+
variable is a human decision, not an automated one.
|
|
29
|
+
- It does not parse code as an AST. It matches text patterns, so a variable
|
|
30
|
+
named only in a comment still counts as used. That is deliberate: it errs
|
|
31
|
+
toward keeping a variable documented rather than deleting it.
|
|
32
|
+
- Shell scripts only match uppercase names (`$HOME` counts, `$home` does not),
|
|
33
|
+
because lowercase shell words are locals by convention, not env vars.
|
|
34
|
+
- It sends nothing anywhere. Everything runs locally, Python standard
|
|
35
|
+
library only.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
No install step and no dependencies — Python 3.10+ is enough:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
git clone https://github.com/haiderstudio/env-example-sync-cli.git
|
|
43
|
+
cd env-example-sync-cli
|
|
44
|
+
python3 src/env_example_sync.py --help
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
From your project root, where `.env.example` lives:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
python3 src/env_example_sync.py .
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Real output on the bundled sample project (see `examples/sample-project`,
|
|
56
|
+
where `TIMEOUT` is used but undocumented and `OLD_VAR` is documented but
|
|
57
|
+
unused):
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
Checked: 1 file(s)
|
|
61
|
+
Used in code: 3
|
|
62
|
+
Documented in .env.example: 3
|
|
63
|
+
Missing from .env.example (used but not documented):
|
|
64
|
+
- TIMEOUT
|
|
65
|
+
Extra in .env.example (documented but not used):
|
|
66
|
+
- OLD_VAR
|
|
67
|
+
Result: DRIFT
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
That run exits with code 1 — exactly what a CI gate wants.
|
|
71
|
+
|
|
72
|
+
Other useful forms:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
# Machine-readable output for another tool to consume
|
|
76
|
+
python3 src/env_example_sync.py . --format json
|
|
77
|
+
|
|
78
|
+
# Ignore variables that are set by the platform, not by the project
|
|
79
|
+
python3 src/env_example_sync.py . --ignore PATH,HOME
|
|
80
|
+
|
|
81
|
+
# Append only the missing variables (as NAME=), then re-check
|
|
82
|
+
python3 src/env_example_sync.py . --fix
|
|
83
|
+
|
|
84
|
+
# Scan a different project, with its example file elsewhere
|
|
85
|
+
python3 src/env_example_sync.py /path/to/project --example /path/to/project/.env.example
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Examples
|
|
89
|
+
|
|
90
|
+
**Example 1 — a clean project passes.** A project whose code uses `API_KEY`
|
|
91
|
+
and whose `.env.example` lists exactly `API_KEY` prints `Result: IN SYNC`
|
|
92
|
+
and exits 0.
|
|
93
|
+
|
|
94
|
+
**Example 2 — the CI gate.** Add this to a workflow and a pull request that
|
|
95
|
+
adds a new env var without documenting it fails the build until
|
|
96
|
+
`.env.example` is updated (or `--fix` is run and the result committed):
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
- run: python3 src/env_example_sync.py .
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Example 3 — the `--fix` edge case.** If a project has both a missing and
|
|
103
|
+
an extra variable, `--fix` appends the missing one but the run still exits
|
|
104
|
+
1, because the extra variable still needs a human decision. Nothing is
|
|
105
|
+
silently deleted.
|
|
106
|
+
|
|
107
|
+
## How it is tested
|
|
108
|
+
|
|
109
|
+
Standard library `unittest` only — 15 tests covering example parsing,
|
|
110
|
+
every language pattern, missing/extra classification, exit codes, JSON
|
|
111
|
+
output, `--fix`, `--ignore`, and excluded directories:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
python3 tests/test_env_example_sync.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
All 15 pass on the files in this repo. The README's sample output above is
|
|
118
|
+
the tool's real output on `examples/sample-project`, not a mock.
|
|
119
|
+
|
|
120
|
+
## Related tools (and how this one differs)
|
|
121
|
+
|
|
122
|
+
Similar drift checkers exist, mostly for JavaScript projects: `dotenv-audit`
|
|
123
|
+
(Node, scans JS/TS and can auto-fill `.env.example` with generated
|
|
124
|
+
placeholder values) and `dotdrift` (Node, compares `.env` with
|
|
125
|
+
`.env.example`). This tool is the Python-stdlib alternative: it needs no
|
|
126
|
+
Node or npm, covers Python, JS/TS, Go, PHP, and shell in one pass, never
|
|
127
|
+
reads a real `.env` file, and `--fix` appends empty `NAME=` lines only —
|
|
128
|
+
it never invents a placeholder value that could be mistaken for a real one.
|
|
129
|
+
|
|
130
|
+
## Contributing
|
|
131
|
+
|
|
132
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). Small, focused improvements are
|
|
133
|
+
welcome; never weaken the safety rules (no secret values, no automatic
|
|
134
|
+
deletion, no invented values).
|
|
135
|
+
|
|
136
|
+
## Licence
|
|
137
|
+
|
|
138
|
+
MIT — see [LICENSE](LICENSE). This is original work, written for this repo;
|
|
139
|
+
the drift-checking idea is a common developer workflow, not copied code.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "env-example-sync-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Scan code for environment variables, compare with `.env.example`, and fail CI when they drift."
|
|
9
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Haider Ali" }]
|
|
13
|
+
keywords = ["env", "dotenv", "environment-variables", "cli", "developer-tools"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development",
|
|
21
|
+
]
|
|
22
|
+
urls = { Homepage = "https://github.com/haiderstudio/env-example-sync-cli", Repository = "https://github.com/haiderstudio/env-example-sync-cli", Issues = "https://github.com/haiderstudio/env-example-sync-cli/issues" }
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
env-example-sync = "env_example_sync:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-dir]
|
|
28
|
+
"" = "src"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
py-modules = ["env_example_sync"]
|
|
@@ -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
|
+
[](https://github.com/haiderstudio/env-example-sync-cli/actions/workflows/ci.yml) [](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,10 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/env_example_sync.py
|
|
5
|
+
src/env_example_sync_cli.egg-info/PKG-INFO
|
|
6
|
+
src/env_example_sync_cli.egg-info/SOURCES.txt
|
|
7
|
+
src/env_example_sync_cli.egg-info/dependency_links.txt
|
|
8
|
+
src/env_example_sync_cli.egg-info/entry_points.txt
|
|
9
|
+
src/env_example_sync_cli.egg-info/top_level.txt
|
|
10
|
+
tests/test_env_example_sync.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
env_example_sync
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tests for env_example_sync. Standard library only; run with:
|
|
3
|
+
|
|
4
|
+
python3 tests/test_env_example_sync.py
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import unittest
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
15
|
+
|
|
16
|
+
from env_example_sync import check, parse_example, scan_text # noqa: E402
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write(path, text):
|
|
20
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
path.write_text(text, encoding="utf-8")
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestParseExample(unittest.TestCase):
|
|
26
|
+
def test_comments_export_and_values_with_equals(self):
|
|
27
|
+
text = (
|
|
28
|
+
"# comment\n"
|
|
29
|
+
"\n"
|
|
30
|
+
"export API_KEY=abc\n"
|
|
31
|
+
"DATABASE_URL=postgres://host/db?x=1\n"
|
|
32
|
+
"EMPTY=\n"
|
|
33
|
+
"not a pair\n"
|
|
34
|
+
)
|
|
35
|
+
self.assertEqual(
|
|
36
|
+
parse_example(text), {"API_KEY", "DATABASE_URL", "EMPTY"})
|
|
37
|
+
|
|
38
|
+
def test_empty_example(self):
|
|
39
|
+
self.assertEqual(parse_example(""), set())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestScanText(unittest.TestCase):
|
|
43
|
+
def test_python_patterns(self):
|
|
44
|
+
text = (
|
|
45
|
+
"a = os.environ['API_KEY']\n"
|
|
46
|
+
'b = os.environ.get("DATABASE_URL")\n'
|
|
47
|
+
"c = os.getenv('TIMEOUT')\n"
|
|
48
|
+
)
|
|
49
|
+
self.assertEqual(
|
|
50
|
+
scan_text(text), {"API_KEY", "DATABASE_URL", "TIMEOUT"})
|
|
51
|
+
|
|
52
|
+
def test_js_ts_patterns(self):
|
|
53
|
+
text = (
|
|
54
|
+
"const k = process.env.STRIPE_KEY;\n"
|
|
55
|
+
"const s = process.env['SENTRY_DSN'];\n"
|
|
56
|
+
"const v = import.meta.env.VITE_API_URL;\n"
|
|
57
|
+
)
|
|
58
|
+
self.assertEqual(
|
|
59
|
+
scan_text(text), {"STRIPE_KEY", "SENTRY_DSN", "VITE_API_URL"})
|
|
60
|
+
|
|
61
|
+
def test_shell_and_go_and_php_patterns(self):
|
|
62
|
+
text = (
|
|
63
|
+
'd = os.Getenv("GO_KEY")\n'
|
|
64
|
+
"e = getenv('PHP_KEY');\n"
|
|
65
|
+
"f = $_ENV['SERVER_KEY'];\n"
|
|
66
|
+
"echo ${SHELL_KEY} and $PLAIN_KEY\n"
|
|
67
|
+
)
|
|
68
|
+
self.assertEqual(
|
|
69
|
+
scan_text(text),
|
|
70
|
+
{"GO_KEY", "PHP_KEY", "SERVER_KEY", "SHELL_KEY", "PLAIN_KEY"})
|
|
71
|
+
|
|
72
|
+
def test_lowercase_shell_words_are_not_variables(self):
|
|
73
|
+
# Shell locals are lowercase by convention, so $home is a local
|
|
74
|
+
# variable, not an environment variable, and is not flagged.
|
|
75
|
+
self.assertEqual(scan_text("echo $home\n"), set())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestCheck(unittest.TestCase):
|
|
79
|
+
def test_missing_and_extra(self):
|
|
80
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
81
|
+
root = Path(tmp)
|
|
82
|
+
write(root / "app.py",
|
|
83
|
+
"import os\nk = os.environ['API_KEY']\n")
|
|
84
|
+
example = write(root / ".env.example",
|
|
85
|
+
"API_KEY=\nOLD_VAR=\n")
|
|
86
|
+
result = check(root, example, [])
|
|
87
|
+
self.assertEqual(result["missing"], [])
|
|
88
|
+
self.assertEqual(result["extra"], ["OLD_VAR"])
|
|
89
|
+
self.assertFalse(result["in_sync"])
|
|
90
|
+
|
|
91
|
+
def test_in_sync(self):
|
|
92
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
93
|
+
root = Path(tmp)
|
|
94
|
+
write(root / "app.py",
|
|
95
|
+
"import os\nk = os.environ['API_KEY']\n")
|
|
96
|
+
example = write(root / ".env.example", "API_KEY=\n")
|
|
97
|
+
self.assertTrue(check(root, example, [])["in_sync"])
|
|
98
|
+
|
|
99
|
+
def test_missing_example_file_means_all_used_are_missing(self):
|
|
100
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
101
|
+
root = Path(tmp)
|
|
102
|
+
write(root / "app.py",
|
|
103
|
+
"import os\nk = os.environ['API_KEY']\n")
|
|
104
|
+
result = check(root, root / ".env.example", [])
|
|
105
|
+
self.assertEqual(result["missing"], ["API_KEY"])
|
|
106
|
+
|
|
107
|
+
def test_ignore_list(self):
|
|
108
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
109
|
+
root = Path(tmp)
|
|
110
|
+
write(root / "app.py",
|
|
111
|
+
"import os\nk = os.environ['API_KEY']\n")
|
|
112
|
+
example = write(root / ".env.example", "")
|
|
113
|
+
self.assertTrue(check(root, example, ["API_KEY"])["in_sync"])
|
|
114
|
+
|
|
115
|
+
def test_excluded_dirs_are_not_scanned(self):
|
|
116
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
117
|
+
root = Path(tmp)
|
|
118
|
+
write(root / "node_modules" / "pkg" / "index.js",
|
|
119
|
+
"const x = process.env.HIDDEN_KEY;\n")
|
|
120
|
+
write(root / "app.py", "import os\nk = os.environ['API_KEY']\n")
|
|
121
|
+
example = write(root / ".env.example", "API_KEY=\n")
|
|
122
|
+
result = check(root, example, [])
|
|
123
|
+
self.assertEqual(result["used"], ["API_KEY"])
|
|
124
|
+
self.assertEqual(result["files_checked"], 1)
|
|
125
|
+
|
|
126
|
+
def test_env_files_are_not_scanned_as_code(self):
|
|
127
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
128
|
+
root = Path(tmp)
|
|
129
|
+
write(root / ".env", "SECRET_IN_ENV_FILE=1\n")
|
|
130
|
+
write(root / "app.py", "print('nothing')\n")
|
|
131
|
+
example = write(root / ".env.example", "")
|
|
132
|
+
result = check(root, example, [])
|
|
133
|
+
self.assertEqual(result["used"], [])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestCli(unittest.TestCase):
|
|
137
|
+
def run_tool(self, *args, cwd):
|
|
138
|
+
tool = Path(__file__).resolve().parents[1] / "src" / "env_example_sync.py"
|
|
139
|
+
return subprocess.run(
|
|
140
|
+
[sys.executable, str(tool), *args],
|
|
141
|
+
cwd=cwd, capture_output=True, text=True, check=False)
|
|
142
|
+
|
|
143
|
+
def test_drift_exits_1_and_sync_exits_0(self):
|
|
144
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
145
|
+
root = Path(tmp)
|
|
146
|
+
write(root / "app.py",
|
|
147
|
+
"import os\nk = os.environ['API_KEY']\n")
|
|
148
|
+
write(root / ".env.example", "API_KEY=\nOLD_VAR=\n")
|
|
149
|
+
drift = self.run_tool(".", cwd=tmp)
|
|
150
|
+
self.assertEqual(drift.returncode, 1)
|
|
151
|
+
self.assertIn("OLD_VAR", drift.stdout)
|
|
152
|
+
write(root / ".env.example", "API_KEY=\n")
|
|
153
|
+
sync = self.run_tool(".", cwd=tmp)
|
|
154
|
+
self.assertEqual(sync.returncode, 0)
|
|
155
|
+
self.assertIn("IN SYNC", sync.stdout)
|
|
156
|
+
|
|
157
|
+
def test_json_output(self):
|
|
158
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
159
|
+
root = Path(tmp)
|
|
160
|
+
write(root / "app.py",
|
|
161
|
+
"import os\nk = os.environ['API_KEY']\n")
|
|
162
|
+
write(root / ".env.example", "")
|
|
163
|
+
proc = self.run_tool(".", "--format", "json", cwd=tmp)
|
|
164
|
+
data = json.loads(proc.stdout)
|
|
165
|
+
self.assertEqual(data["missing"], ["API_KEY"])
|
|
166
|
+
self.assertFalse(data["in_sync"])
|
|
167
|
+
|
|
168
|
+
def test_fix_appends_missing_only(self):
|
|
169
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
170
|
+
root = Path(tmp)
|
|
171
|
+
write(root / "app.py",
|
|
172
|
+
"import os\nk = os.environ['API_KEY']\n"
|
|
173
|
+
"j = os.environ['SECOND_KEY']\n")
|
|
174
|
+
write(root / ".env.example", "API_KEY=\nOLD_VAR=\n")
|
|
175
|
+
proc = self.run_tool(".", "--fix", cwd=tmp)
|
|
176
|
+
# OLD_VAR is still extra, so drift remains -- but SECOND_KEY
|
|
177
|
+
# must now be documented, and API_KEY must not be duplicated.
|
|
178
|
+
self.assertEqual(proc.returncode, 1)
|
|
179
|
+
content = (root / ".env.example").read_text(encoding="utf-8")
|
|
180
|
+
self.assertEqual(content.count("API_KEY="), 1)
|
|
181
|
+
self.assertIn("SECOND_KEY=\n", content)
|
|
182
|
+
self.assertIn("OLD_VAR=\n", content)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
unittest.main()
|