moles-tools 0.0.1__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,68 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ pip-wheel-metadata/
20
+ share/python-wheels/
21
+ *.egg-info/
22
+ .installed.cfg
23
+ *.egg
24
+ MANIFEST
25
+
26
+ # Virtual environments
27
+ .venv/
28
+ venv/
29
+ ENV/
30
+ env/
31
+
32
+ # uv
33
+ .python-version
34
+
35
+ # Distribution / packaging
36
+ dist/
37
+
38
+ # Testing
39
+ .pytest_cache/
40
+ .coverage
41
+ coverage.xml
42
+ pytest-coverage.txt
43
+ pytest.xml
44
+ htmlcov/
45
+
46
+ # Type checking
47
+ .mypy_cache/
48
+
49
+ # Linting
50
+ .ruff_cache/
51
+
52
+ # IDE
53
+ .idea/
54
+ .vscode/
55
+ *.swp
56
+ *.swo
57
+ *~
58
+
59
+ # OS
60
+ .DS_Store
61
+ Thumbs.db
62
+
63
+ # Node.js
64
+ node_modules/
65
+ docs/node_modules/
66
+ docs/.vitepress/dist/
67
+ docs/.vitepress/cache/
68
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Glaser
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,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: moles-tools
3
+ Version: 0.0.1
4
+ Summary: A collection of Python tools from the underground
5
+ Project-URL: Homepage, https://github.com/the78mole/moles-tools
6
+ Project-URL: Documentation, https://the78mole.github.io/moles-tools
7
+ Project-URL: Repository, https://github.com/the78mole/moles-tools
8
+ Project-URL: Issues, https://github.com/the78mole/moles-tools/issues
9
+ Project-URL: Changelog, https://github.com/the78mole/moles-tools/releases
10
+ Author-email: the78mole <the78mole@users.noreply.github.com>
11
+ License: MIT
12
+ License-File: LICENSE
13
+ Keywords: cli,env,tools,utilities
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.11
24
+ Provides-Extra: dev
25
+ Requires-Dist: black>=24.0; extra == 'dev'
26
+ Requires-Dist: mypy>=1.10; extra == 'dev'
27
+ Requires-Dist: pre-commit>=3.7; extra == 'dev'
28
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # moles-tools
34
+
35
+ [![CI/CD](https://github.com/the78mole/moles-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/the78mole/moles-tools/actions/workflows/ci.yml)
36
+ [![Documentation](https://github.com/the78mole/moles-tools/actions/workflows/docs.yml/badge.svg)](https://the78mole.github.io/moles-tools)
37
+ [![PyPI version](https://badge.fury.io/py/moles-tools.svg)](https://badge.fury.io/py/moles-tools)
38
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
40
+
41
+ A collection of Python tools from the underground. 🐾
42
+
43
+ 📖 **[Full Documentation](https://the78mole.github.io/moles-tools)**
44
+
45
+ ## Tools
46
+
47
+ | Tool | Description |
48
+ |---|---|
49
+ | [`env-updater`](https://the78mole.github.io/moles-tools/tools/env-updater) | Update ENV variables in a target file from a source file |
50
+
51
+ ## Quick Start
52
+
53
+ ```bash
54
+ # Install from PyPI
55
+ pip install moles-tools
56
+
57
+ # Or with uv
58
+ uv add moles-tools
59
+
60
+ # Update .env from .env.production
61
+ env-updater .env.production .env
62
+ ```
63
+
64
+ ## Development
65
+
66
+ ```bash
67
+ # Install uv
68
+ curl -LsSf https://astral.sh/uv/install.sh | sh
69
+
70
+ # Clone and set up
71
+ git clone https://github.com/the78mole/moles-tools.git
72
+ cd moles-tools
73
+
74
+ # Install all dependencies
75
+ uv sync --all-extras
76
+
77
+ # Install pre-commit hooks
78
+ uv run pre-commit install
79
+
80
+ # Run tests
81
+ uv run pytest
82
+ ```
83
+
84
+ ## License
85
+
86
+ [MIT](LICENSE)
@@ -0,0 +1,54 @@
1
+ # moles-tools
2
+
3
+ [![CI/CD](https://github.com/the78mole/moles-tools/actions/workflows/ci.yml/badge.svg)](https://github.com/the78mole/moles-tools/actions/workflows/ci.yml)
4
+ [![Documentation](https://github.com/the78mole/moles-tools/actions/workflows/docs.yml/badge.svg)](https://the78mole.github.io/moles-tools)
5
+ [![PyPI version](https://badge.fury.io/py/moles-tools.svg)](https://badge.fury.io/py/moles-tools)
6
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
8
+
9
+ A collection of Python tools from the underground. 🐾
10
+
11
+ 📖 **[Full Documentation](https://the78mole.github.io/moles-tools)**
12
+
13
+ ## Tools
14
+
15
+ | Tool | Description |
16
+ |---|---|
17
+ | [`env-updater`](https://the78mole.github.io/moles-tools/tools/env-updater) | Update ENV variables in a target file from a source file |
18
+
19
+ ## Quick Start
20
+
21
+ ```bash
22
+ # Install from PyPI
23
+ pip install moles-tools
24
+
25
+ # Or with uv
26
+ uv add moles-tools
27
+
28
+ # Update .env from .env.production
29
+ env-updater .env.production .env
30
+ ```
31
+
32
+ ## Development
33
+
34
+ ```bash
35
+ # Install uv
36
+ curl -LsSf https://astral.sh/uv/install.sh | sh
37
+
38
+ # Clone and set up
39
+ git clone https://github.com/the78mole/moles-tools.git
40
+ cd moles-tools
41
+
42
+ # Install all dependencies
43
+ uv sync --all-extras
44
+
45
+ # Install pre-commit hooks
46
+ uv run pre-commit install
47
+
48
+ # Run tests
49
+ uv run pytest
50
+ ```
51
+
52
+ ## License
53
+
54
+ [MIT](LICENSE)
@@ -0,0 +1,99 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "moles-tools"
7
+ version = "0.0.1"
8
+ description = "A collection of Python tools from the underground"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "the78mole", email = "the78mole@users.noreply.github.com" }]
13
+ keywords = ["tools", "env", "utilities", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Libraries :: Python Modules",
23
+ "Topic :: Utilities",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0",
30
+ "pytest-cov>=5.0",
31
+ "black>=24.0",
32
+ "ruff>=0.4.0",
33
+ "mypy>=1.10",
34
+ "pre-commit>=3.7",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/the78mole/moles-tools"
39
+ Documentation = "https://the78mole.github.io/moles-tools"
40
+ Repository = "https://github.com/the78mole/moles-tools"
41
+ Issues = "https://github.com/the78mole/moles-tools/issues"
42
+ Changelog = "https://github.com/the78mole/moles-tools/releases"
43
+
44
+ [project.scripts]
45
+ env-updater = "moles_tools.env_updater:main"
46
+ moles-tools = "moles_tools.__main__:main"
47
+
48
+ [tool.hatch.build.targets.wheel]
49
+ packages = ["src/moles_tools"]
50
+
51
+ [tool.hatch.build.targets.sdist]
52
+ include = ["/src", "/tests", "/README.md", "/LICENSE"]
53
+
54
+ [dependency-groups]
55
+ dev = [
56
+ "pytest>=8.0",
57
+ "pytest-cov>=5.0",
58
+ "black>=24.0",
59
+ "ruff>=0.4.0",
60
+ "mypy>=1.10",
61
+ "pre-commit>=3.7",
62
+ ]
63
+
64
+ [tool.black]
65
+ line-length = 88
66
+ target-version = ["py311", "py312"]
67
+
68
+ [tool.ruff]
69
+ line-length = 88
70
+ target-version = "py311"
71
+
72
+ [tool.ruff.lint]
73
+ select = ["E", "F", "I", "N", "W", "B", "UP"]
74
+ ignore = ["E501"]
75
+
76
+ [tool.ruff.lint.isort]
77
+ known-first-party = ["moles_tools"]
78
+
79
+ [tool.mypy]
80
+ python_version = "3.11"
81
+ strict = true
82
+ warn_return_any = true
83
+ warn_unused_configs = true
84
+
85
+ [tool.pytest.ini_options]
86
+ testpaths = ["tests"]
87
+ addopts = "-v --cov=moles_tools --cov-report=term-missing --cov-report=xml"
88
+
89
+ [tool.coverage.run]
90
+ source = ["src/moles_tools"]
91
+ branch = true
92
+
93
+ [tool.coverage.report]
94
+ show_missing = true
95
+ fail_under = 80
96
+
97
+ [tool.bandit]
98
+ exclude_dirs = ["tests"]
99
+ skips = []
@@ -0,0 +1,9 @@
1
+ """moles-tools: A collection of Python tools from the underground."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "the78mole"
5
+ __license__ = "MIT"
6
+
7
+ from moles_tools.env_updater import update_env_file
8
+
9
+ __all__ = ["update_env_file"]
@@ -0,0 +1,20 @@
1
+ """CLI entry point for moles-tools."""
2
+
3
+ import sys
4
+
5
+
6
+ def main() -> None:
7
+ """Show available tools when moles-tools is called directly."""
8
+ tools = {
9
+ "env-updater": "Update ENV variables in a target file from a source file",
10
+ }
11
+ print("moles-tools - A collection of Python tools from the underground\n")
12
+ print("Available tools:")
13
+ for name, description in tools.items():
14
+ print(f" {name:<20} {description}")
15
+ print("\nRun 'env-updater --help' for usage information.")
16
+ sys.exit(0)
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()
@@ -0,0 +1,302 @@
1
+ """ENV File Updater: Updates ENV variables in a target file from a source file.
2
+
3
+ This tool reads all ENV variables from a source file and updates the
4
+ corresponding variables in a target file. Variables that exist in the source
5
+ but not in the target are appended at the end of the target file. Comments
6
+ and blank lines in the target file are preserved.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import shutil
13
+ import sys
14
+ from pathlib import Path
15
+
16
+
17
+ def parse_env_file(file_path: str | Path) -> dict[str, str]:
18
+ """Parse an ENV file and return a dict of key-value pairs.
19
+
20
+ Skips comments (lines starting with '#') and blank lines.
21
+ Handles values containing '=' characters correctly.
22
+
23
+ Args:
24
+ file_path: Path to the ENV file to parse.
25
+
26
+ Returns:
27
+ Dictionary mapping variable names to their values.
28
+
29
+ Raises:
30
+ FileNotFoundError: If the file does not exist.
31
+ ValueError: If a non-comment, non-blank line lacks an '=' separator.
32
+ """
33
+ variables: dict[str, str] = {}
34
+ path = Path(file_path)
35
+
36
+ with path.open("r", encoding="utf-8") as fh:
37
+ for lineno, raw_line in enumerate(fh, start=1):
38
+ line = raw_line.rstrip("\n")
39
+ stripped = line.strip()
40
+
41
+ # Skip blank lines and comment lines
42
+ if not stripped or stripped.startswith("#"):
43
+ continue
44
+
45
+ if "=" not in line:
46
+ raise ValueError(
47
+ f"{file_path}:{lineno}: Line has no '=' separator: {line!r}"
48
+ )
49
+
50
+ key, _, value = line.partition("=")
51
+ variables[key.strip()] = value
52
+
53
+ return variables
54
+
55
+
56
+ def update_env_file(
57
+ source_path: str | Path,
58
+ target_path: str | Path,
59
+ *,
60
+ create_target: bool = True,
61
+ ) -> tuple[int, int]:
62
+ """Update ENV variables in *target_path* with values from *source_path*.
63
+
64
+ For each KEY=VALUE entry in the source file:
65
+ - If KEY already exists in the target, its value is updated in place.
66
+ - If KEY does not exist in the target, it is appended at the end.
67
+ Comments and blank lines in the target are preserved unchanged.
68
+
69
+ Args:
70
+ source_path: Path to the source ENV file (read-only).
71
+ target_path: Path to the target ENV file to update.
72
+ create_target: If True and *target_path* does not exist, it is created.
73
+ If False and *target_path* does not exist, FileNotFoundError is
74
+ raised.
75
+
76
+ Returns:
77
+ A tuple ``(updated, added)`` with the count of variables that were
78
+ updated in place and the count of new variables appended.
79
+
80
+ Raises:
81
+ FileNotFoundError: If *source_path* does not exist, or if
82
+ *target_path* does not exist and *create_target* is False.
83
+ """
84
+ source = Path(source_path)
85
+ target = Path(target_path)
86
+
87
+ if not source.exists():
88
+ raise FileNotFoundError(f"Source file not found: {source}")
89
+
90
+ if not target.exists() and not create_target:
91
+ raise FileNotFoundError(f"Target file not found: {target}")
92
+
93
+ source_vars = parse_env_file(source)
94
+
95
+ # Read the existing target lines (if any) --------------------------------
96
+ target_lines: list[str] = []
97
+ if target.exists():
98
+ with target.open("r", encoding="utf-8") as fh:
99
+ target_lines = [line.rstrip("\n") for line in fh]
100
+
101
+ # Update existing keys in-place ------------------------------------------
102
+ updated = 0
103
+ found_keys: set[str] = set()
104
+
105
+ for idx, line in enumerate(target_lines):
106
+ stripped = line.strip()
107
+ if stripped.startswith("#") or not stripped or "=" not in line:
108
+ continue
109
+
110
+ key, _, _ = line.partition("=")
111
+ key = key.strip()
112
+ found_keys.add(key)
113
+
114
+ if key in source_vars:
115
+ new_line = f"{key}={source_vars[key]}"
116
+ if new_line != line:
117
+ target_lines[idx] = new_line
118
+ updated += 1
119
+
120
+ # Append new keys --------------------------------------------------------
121
+ added = 0
122
+ new_lines: list[str] = []
123
+ for key, value in source_vars.items():
124
+ if key not in found_keys:
125
+ new_lines.append(f"{key}={value}")
126
+ added += 1
127
+
128
+ if new_lines:
129
+ # Add a blank separator line if the target is non-empty and does not
130
+ # already end with a blank line.
131
+ if target_lines and target_lines[-1].strip():
132
+ target_lines.append("")
133
+ target_lines.extend(new_lines)
134
+
135
+ # Write the result -------------------------------------------------------
136
+ content = "\n".join(target_lines)
137
+ if content and not content.endswith("\n"):
138
+ content += "\n"
139
+
140
+ with target.open("w", encoding="utf-8") as fh:
141
+ fh.write(content)
142
+
143
+ return updated, added
144
+
145
+
146
+ def find_example_env(cwd: Path) -> Path | None:
147
+ """Find `.env.example` or `env.example` in *cwd*.
148
+
149
+ Args:
150
+ cwd: Directory to search in.
151
+
152
+ Returns:
153
+ Path to the first found example file, or None if neither exists.
154
+ """
155
+ for name in (".env.example", "env.example"):
156
+ candidate = cwd / name
157
+ if candidate.exists():
158
+ return candidate
159
+ return None
160
+
161
+
162
+ def _build_parser() -> argparse.ArgumentParser:
163
+ parser = argparse.ArgumentParser(
164
+ prog="env-updater",
165
+ description=(
166
+ "Update ENV variables in TARGET from UPDATE.\n\n"
167
+ "When TARGET is omitted the tool auto-detects the target:\n"
168
+ " 1. .env exists → update .env with UPDATE\n"
169
+ " 2. .env missing, .env.example/.env found → create .env from\n"
170
+ " example, then apply UPDATE\n"
171
+ " 3. No UPDATE given, .env missing → copy .env.example / env.example\n"
172
+ " to .env\n"
173
+ ),
174
+ formatter_class=argparse.RawDescriptionHelpFormatter,
175
+ )
176
+ parser.add_argument(
177
+ "update",
178
+ metavar="UPDATE",
179
+ nargs="?",
180
+ default=None,
181
+ help="ENV file whose values take precedence (optional).",
182
+ )
183
+ parser.add_argument(
184
+ "target",
185
+ metavar="TARGET",
186
+ nargs="?",
187
+ default=None,
188
+ help=(
189
+ "Target ENV file to update in-place. "
190
+ "Auto-detected from the current directory when omitted."
191
+ ),
192
+ )
193
+ parser.add_argument(
194
+ "--no-create",
195
+ dest="create_target",
196
+ action="store_false",
197
+ default=True,
198
+ help="Fail if TARGET does not exist instead of creating it.",
199
+ )
200
+ parser.add_argument(
201
+ "--quiet",
202
+ "-q",
203
+ action="store_true",
204
+ default=False,
205
+ help="Suppress informational output.",
206
+ )
207
+ return parser
208
+
209
+
210
+ def main(argv: list[str] | None = None) -> int:
211
+ """CLI entry point for the env-updater tool.
212
+
213
+ Args:
214
+ argv: Argument list (defaults to sys.argv[1:]).
215
+
216
+ Returns:
217
+ Exit code (0 on success, non-zero on error).
218
+ """
219
+ parser = _build_parser()
220
+ args = parser.parse_args(argv)
221
+
222
+ cwd = Path.cwd()
223
+ update_path: Path | None = Path(args.update) if args.update else None
224
+ target_path: Path
225
+ copied_example_name: str | None = None
226
+
227
+ if args.target is not None:
228
+ target_path = Path(args.target)
229
+ else:
230
+ # Auto-detect target in the current working directory
231
+ dot_env = cwd / ".env"
232
+ example = find_example_env(cwd)
233
+
234
+ if update_path is not None:
235
+ if dot_env.exists():
236
+ # Case 1: .env exists → update it
237
+ target_path = dot_env
238
+ elif example is not None:
239
+ # Case 2: no .env, but example → copy then apply update
240
+ shutil.copy2(example, dot_env)
241
+ copied_example_name = example.name
242
+ target_path = dot_env
243
+ else:
244
+ print(
245
+ "Error: No .env, .env.example or env.example found "
246
+ "in the current directory.",
247
+ file=sys.stderr,
248
+ )
249
+ return 1
250
+ else:
251
+ # Case 3: no update file — just copy example → .env
252
+ if not dot_env.exists() and example is not None:
253
+ shutil.copy2(example, dot_env)
254
+ if not args.quiet:
255
+ print(f"env-updater: Created .env from {example.name}")
256
+ return 0
257
+ elif dot_env.exists():
258
+ if not args.quiet:
259
+ print("env-updater: .env already exists, nothing to do.")
260
+ return 0
261
+ else:
262
+ print(
263
+ "Error: No .env, .env.example or env.example found "
264
+ "in the current directory.",
265
+ file=sys.stderr,
266
+ )
267
+ return 1
268
+
269
+ if update_path is None:
270
+ print("Error: No UPDATE file specified.", file=sys.stderr)
271
+ return 1
272
+
273
+ try:
274
+ updated, added = update_env_file(
275
+ update_path,
276
+ target_path,
277
+ create_target=args.create_target,
278
+ )
279
+ except FileNotFoundError as exc:
280
+ print(f"Error: {exc}", file=sys.stderr)
281
+ return 1
282
+ except ValueError as exc:
283
+ print(f"Error: {exc}", file=sys.stderr)
284
+ return 1
285
+
286
+ if not args.quiet:
287
+ if copied_example_name:
288
+ print(
289
+ f"env-updater: Created .env from {copied_example_name}, "
290
+ f"then {updated} variable(s) updated, {added} variable(s) added."
291
+ )
292
+ else:
293
+ print(
294
+ f"env-updater: {target_path.name} — "
295
+ f"{updated} variable(s) updated, {added} variable(s) added."
296
+ )
297
+
298
+ return 0
299
+
300
+
301
+ if __name__ == "__main__":
302
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Tests for moles_tools package."""
@@ -0,0 +1,16 @@
1
+ # Application settings
2
+ APP_NAME=MyApp
3
+ APP_ENV=production
4
+ APP_DEBUG=false
5
+ APP_PORT=443
6
+
7
+ # Database
8
+ DB_HOST=db.internal.example.com
9
+ DB_PORT=5432
10
+ DB_NAME=myapp_prod
11
+ DB_USER=prod_user
12
+ DB_PASSWORD=super_secret_prod_password
13
+
14
+ # Secret keys
15
+ SECRET_KEY=v3rY$tr0ngS3cr3t!
16
+ API_KEY=prod-api-key-abc123xyz
@@ -0,0 +1,5 @@
1
+ # Application settings
2
+
3
+ APP_PORT=8080
4
+
5
+ JUST_A_NEW_KEY=DIDNOTEXIST
@@ -0,0 +1,423 @@
1
+ """Tests for the ENV File Updater tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import textwrap
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from moles_tools.env_updater import (
11
+ find_example_env,
12
+ main,
13
+ parse_env_file,
14
+ update_env_file,
15
+ )
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+
22
+ def write_env(path: Path, content: str) -> None:
23
+ """Write *content* (dedented) to *path*."""
24
+ path.write_text(textwrap.dedent(content), encoding="utf-8")
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # parse_env_file
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ class TestParseEnvFile:
33
+ def test_basic_key_value(self, tmp_path: Path) -> None:
34
+ f = tmp_path / ".env"
35
+ write_env(f, "FOO=bar\nBAZ=qux\n")
36
+ result = parse_env_file(f)
37
+ assert result == {"FOO": "bar", "BAZ": "qux"}
38
+
39
+ def test_skips_comments(self, tmp_path: Path) -> None:
40
+ f = tmp_path / ".env"
41
+ write_env(f, "# This is a comment\nFOO=bar\n")
42
+ result = parse_env_file(f)
43
+ assert result == {"FOO": "bar"}
44
+ assert len(result) == 1
45
+
46
+ def test_skips_blank_lines(self, tmp_path: Path) -> None:
47
+ f = tmp_path / ".env"
48
+ write_env(f, "\nFOO=bar\n\nBAZ=qux\n")
49
+ result = parse_env_file(f)
50
+ assert result == {"FOO": "bar", "BAZ": "qux"}
51
+
52
+ def test_value_containing_equals(self, tmp_path: Path) -> None:
53
+ f = tmp_path / ".env"
54
+ write_env(f, "URL=http://example.com?a=1&b=2\n")
55
+ result = parse_env_file(f)
56
+ assert result == {"URL": "http://example.com?a=1&b=2"}
57
+
58
+ def test_empty_value(self, tmp_path: Path) -> None:
59
+ f = tmp_path / ".env"
60
+ write_env(f, "EMPTY=\n")
61
+ result = parse_env_file(f)
62
+ assert result == {"EMPTY": ""}
63
+
64
+ def test_file_not_found(self, tmp_path: Path) -> None:
65
+ with pytest.raises(FileNotFoundError):
66
+ parse_env_file(tmp_path / "nonexistent.env")
67
+
68
+ def test_line_without_equals_raises(self, tmp_path: Path) -> None:
69
+ f = tmp_path / ".env"
70
+ write_env(f, "NODIVIDER\n")
71
+ with pytest.raises(ValueError, match="no '=' separator"):
72
+ parse_env_file(f)
73
+
74
+ def test_empty_file(self, tmp_path: Path) -> None:
75
+ f = tmp_path / ".env"
76
+ f.write_text("", encoding="utf-8")
77
+ assert parse_env_file(f) == {}
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # update_env_file — happy paths
82
+ # ---------------------------------------------------------------------------
83
+
84
+
85
+ class TestUpdateEnvFileUpdates:
86
+ def test_updates_existing_variable(self, tmp_path: Path) -> None:
87
+ source = tmp_path / "source.env"
88
+ target = tmp_path / "target.env"
89
+ write_env(source, "FOO=new_value\n")
90
+ write_env(target, "FOO=old_value\n")
91
+
92
+ updated, added = update_env_file(source, target)
93
+
94
+ assert updated == 1
95
+ assert added == 0
96
+ lines = target.read_text().splitlines()
97
+ assert "FOO=new_value" in lines
98
+
99
+ def test_adds_missing_variable(self, tmp_path: Path) -> None:
100
+ source = tmp_path / "source.env"
101
+ target = tmp_path / "target.env"
102
+ write_env(source, "NEW_VAR=hello\n")
103
+ write_env(target, "EXISTING=value\n")
104
+
105
+ updated, added = update_env_file(source, target)
106
+
107
+ assert updated == 0
108
+ assert added == 1
109
+ content = target.read_text()
110
+ assert "NEW_VAR=hello" in content
111
+ assert "EXISTING=value" in content
112
+
113
+ def test_updates_and_adds(self, tmp_path: Path) -> None:
114
+ source = tmp_path / "source.env"
115
+ target = tmp_path / "target.env"
116
+ write_env(source, "EXISTING=updated\nNEW=brand_new\n")
117
+ write_env(target, "EXISTING=old\n")
118
+
119
+ updated, added = update_env_file(source, target)
120
+
121
+ assert updated == 1
122
+ assert added == 1
123
+
124
+ def test_preserves_comments(self, tmp_path: Path) -> None:
125
+ source = tmp_path / "source.env"
126
+ target = tmp_path / "target.env"
127
+ write_env(source, "FOO=new\n")
128
+ write_env(target, "# My comment\nFOO=old\n")
129
+
130
+ update_env_file(source, target)
131
+
132
+ content = target.read_text()
133
+ assert "# My comment" in content
134
+ assert "FOO=new" in content
135
+
136
+ def test_preserves_blank_lines(self, tmp_path: Path) -> None:
137
+ source = tmp_path / "source.env"
138
+ target = tmp_path / "target.env"
139
+ write_env(source, "BAR=new\n")
140
+ write_env(target, "FOO=keep\n\nBAR=old\n")
141
+
142
+ update_env_file(source, target)
143
+
144
+ lines = target.read_text().splitlines()
145
+ assert "" in lines # blank line preserved
146
+
147
+ def test_creates_target_when_missing(self, tmp_path: Path) -> None:
148
+ source = tmp_path / "source.env"
149
+ target = tmp_path / "new_target.env"
150
+ write_env(source, "A=1\nB=2\n")
151
+
152
+ assert not target.exists()
153
+ updated, added = update_env_file(source, target)
154
+
155
+ assert target.exists()
156
+ assert updated == 0
157
+ assert added == 2
158
+
159
+ def test_no_change_when_values_identical(self, tmp_path: Path) -> None:
160
+ source = tmp_path / "source.env"
161
+ target = tmp_path / "target.env"
162
+ write_env(source, "FOO=same\n")
163
+ write_env(target, "FOO=same\n")
164
+
165
+ updated, added = update_env_file(source, target)
166
+
167
+ assert updated == 0
168
+ assert added == 0
169
+
170
+ def test_value_with_equals(self, tmp_path: Path) -> None:
171
+ source = tmp_path / "source.env"
172
+ target = tmp_path / "target.env"
173
+ write_env(source, "URL=https://example.com?a=1&b=2\n")
174
+ write_env(target, "URL=old\n")
175
+
176
+ update_env_file(source, target)
177
+
178
+ lines = target.read_text().splitlines()
179
+ assert "URL=https://example.com?a=1&b=2" in lines
180
+
181
+ def test_empty_target_gets_all_source_vars(self, tmp_path: Path) -> None:
182
+ source = tmp_path / "source.env"
183
+ target = tmp_path / "target.env"
184
+ write_env(source, "A=1\nB=2\nC=3\n")
185
+ target.write_text("", encoding="utf-8")
186
+
187
+ updated, added = update_env_file(source, target)
188
+
189
+ assert updated == 0
190
+ assert added == 3
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # update_env_file — error handling
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ class TestUpdateEnvFileErrors:
199
+ def test_source_not_found_raises(self, tmp_path: Path) -> None:
200
+ source = tmp_path / "missing.env"
201
+ target = tmp_path / "target.env"
202
+ target.write_text("", encoding="utf-8")
203
+
204
+ with pytest.raises(FileNotFoundError, match="Source file not found"):
205
+ update_env_file(source, target)
206
+
207
+ def test_no_create_raises_when_target_missing(self, tmp_path: Path) -> None:
208
+ source = tmp_path / "source.env"
209
+ source.write_text("A=1\n", encoding="utf-8")
210
+ target = tmp_path / "missing_target.env"
211
+
212
+ with pytest.raises(FileNotFoundError, match="Target file not found"):
213
+ update_env_file(source, target, create_target=False)
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # CLI (main)
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ class TestMain:
222
+ def test_cli_basic(self, tmp_path: Path) -> None:
223
+ source = tmp_path / "source.env"
224
+ target = tmp_path / "target.env"
225
+ write_env(source, "FOO=new\nBAR=added\n")
226
+ write_env(target, "FOO=old\n")
227
+
228
+ rc = main([str(source), str(target)])
229
+
230
+ assert rc == 0
231
+ content = target.read_text()
232
+ assert "FOO=new" in content
233
+ assert "BAR=added" in content
234
+
235
+ def test_cli_missing_source(self, tmp_path: Path) -> None:
236
+ rc = main([str(tmp_path / "nope.env"), str(tmp_path / "target.env")])
237
+ assert rc == 1
238
+
239
+ def test_cli_no_create_fails(self, tmp_path: Path) -> None:
240
+ source = tmp_path / "source.env"
241
+ source.write_text("A=1\n", encoding="utf-8")
242
+ target = tmp_path / "nonexistent.env"
243
+
244
+ rc = main([str(source), str(target), "--no-create"])
245
+
246
+ assert rc == 1
247
+
248
+ def test_cli_quiet(
249
+ self, tmp_path: Path, capsys: pytest.CaptureFixture[str]
250
+ ) -> None:
251
+ source = tmp_path / "source.env"
252
+ target = tmp_path / "target.env"
253
+ write_env(source, "X=1\n")
254
+ write_env(target, "X=0\n")
255
+
256
+ rc = main([str(source), str(target), "--quiet"])
257
+
258
+ assert rc == 0
259
+ captured = capsys.readouterr()
260
+ assert captured.out == ""
261
+
262
+ def test_cli_creates_target(self, tmp_path: Path) -> None:
263
+ source = tmp_path / "source.env"
264
+ target = tmp_path / "new.env"
265
+ write_env(source, "KEY=val\n")
266
+
267
+ rc = main([str(source), str(target)])
268
+
269
+ assert rc == 0
270
+ assert target.exists()
271
+ assert "KEY=val" in target.read_text()
272
+
273
+
274
+ # ---------------------------------------------------------------------------
275
+ # find_example_env
276
+ # ---------------------------------------------------------------------------
277
+
278
+
279
+ class TestFindExampleEnv:
280
+ def test_finds_dot_env_example(self, tmp_path: Path) -> None:
281
+ (tmp_path / ".env.example").write_text("A=1\n", encoding="utf-8")
282
+ assert find_example_env(tmp_path) == tmp_path / ".env.example"
283
+
284
+ def test_finds_env_example(self, tmp_path: Path) -> None:
285
+ (tmp_path / "env.example").write_text("A=1\n", encoding="utf-8")
286
+ assert find_example_env(tmp_path) == tmp_path / "env.example"
287
+
288
+ def test_prefers_dot_env_example(self, tmp_path: Path) -> None:
289
+ (tmp_path / ".env.example").write_text("A=1\n", encoding="utf-8")
290
+ (tmp_path / "env.example").write_text("A=2\n", encoding="utf-8")
291
+ assert find_example_env(tmp_path) == tmp_path / ".env.example"
292
+
293
+ def test_returns_none_when_not_found(self, tmp_path: Path) -> None:
294
+ assert find_example_env(tmp_path) is None
295
+
296
+
297
+ # ---------------------------------------------------------------------------
298
+ # Auto-detect: main() without explicit TARGET
299
+ # ---------------------------------------------------------------------------
300
+
301
+
302
+ class TestAutoDetect:
303
+ """Tests for the auto-detection logic when TARGET is omitted."""
304
+
305
+ def test_case1_updates_existing_dot_env(
306
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
307
+ ) -> None:
308
+ """Case 1: .env exists → update it with UPDATE file."""
309
+ monkeypatch.chdir(tmp_path)
310
+ update = tmp_path / "update.env"
311
+ dot_env = tmp_path / ".env"
312
+ write_env(update, "FOO=new\nBAR=added\n")
313
+ write_env(dot_env, "FOO=old\n")
314
+
315
+ rc = main([str(update)])
316
+
317
+ assert rc == 0
318
+ content = dot_env.read_text()
319
+ assert "FOO=new" in content
320
+ assert "BAR=added" in content
321
+
322
+ def test_case2_creates_dot_env_from_example(
323
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
324
+ ) -> None:
325
+ """Case 2: no .env, .env.example exists → create .env from it, then apply UPDATE."""
326
+ monkeypatch.chdir(tmp_path)
327
+ update = tmp_path / "update.env"
328
+ example = tmp_path / ".env.example"
329
+ write_env(update, "SECRET=override\n")
330
+ write_env(example, "DB=postgres\nSECRET=changeme\n")
331
+
332
+ rc = main([str(update)])
333
+
334
+ assert rc == 0
335
+ dot_env = tmp_path / ".env"
336
+ assert dot_env.exists()
337
+ content = dot_env.read_text()
338
+ assert "DB=postgres" in content
339
+ assert "SECRET=override" in content
340
+
341
+ def test_case2_uses_env_example_fallback(
342
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
343
+ ) -> None:
344
+ """Case 2: env.example (no leading dot) is used as fallback."""
345
+ monkeypatch.chdir(tmp_path)
346
+ update = tmp_path / "update.env"
347
+ (tmp_path / "env.example").write_text("X=1\n", encoding="utf-8")
348
+ write_env(update, "X=2\n")
349
+
350
+ rc = main([str(update)])
351
+
352
+ assert rc == 0
353
+ content = (tmp_path / ".env").read_text()
354
+ assert "X=2" in content
355
+
356
+ def test_case2_no_example_returns_error(
357
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
358
+ ) -> None:
359
+ """Case 2 failure: no .env and no example → exit 1."""
360
+ monkeypatch.chdir(tmp_path)
361
+ update = tmp_path / "update.env"
362
+ write_env(update, "A=1\n")
363
+
364
+ rc = main([str(update)])
365
+
366
+ assert rc == 1
367
+
368
+ def test_case3_copies_example_to_dot_env(
369
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
370
+ ) -> None:
371
+ """Case 3: no UPDATE, no .env → copy .env.example to .env."""
372
+ monkeypatch.chdir(tmp_path)
373
+ example = tmp_path / ".env.example"
374
+ write_env(example, "HOST=localhost\nPORT=5432\n")
375
+
376
+ rc = main([])
377
+
378
+ assert rc == 0
379
+ dot_env = tmp_path / ".env"
380
+ assert dot_env.exists()
381
+ assert dot_env.read_text() == example.read_text()
382
+
383
+ def test_case3_dot_env_already_exists_noop(
384
+ self,
385
+ tmp_path: Path,
386
+ monkeypatch: pytest.MonkeyPatch,
387
+ capsys: pytest.CaptureFixture[str],
388
+ ) -> None:
389
+ """Case 3: .env already exists, nothing to do."""
390
+ monkeypatch.chdir(tmp_path)
391
+ dot_env = tmp_path / ".env"
392
+ write_env(dot_env, "A=1\n")
393
+
394
+ rc = main([])
395
+
396
+ assert rc == 0
397
+ captured = capsys.readouterr()
398
+ assert "nothing to do" in captured.out
399
+
400
+ def test_case3_no_example_no_dot_env_returns_error(
401
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
402
+ ) -> None:
403
+ """Case 3 failure: no .env and no example → exit 1."""
404
+ monkeypatch.chdir(tmp_path)
405
+
406
+ rc = main([])
407
+
408
+ assert rc == 1
409
+
410
+ def test_case3_quiet_suppresses_output(
411
+ self,
412
+ tmp_path: Path,
413
+ monkeypatch: pytest.MonkeyPatch,
414
+ capsys: pytest.CaptureFixture[str],
415
+ ) -> None:
416
+ """Case 3 with --quiet suppresses output."""
417
+ monkeypatch.chdir(tmp_path)
418
+ write_env(tmp_path / ".env.example", "A=1\n")
419
+
420
+ rc = main(["--quiet"])
421
+
422
+ assert rc == 0
423
+ assert capsys.readouterr().out == ""