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.
- moles_tools-0.0.1/.gitignore +68 -0
- moles_tools-0.0.1/LICENSE +21 -0
- moles_tools-0.0.1/PKG-INFO +86 -0
- moles_tools-0.0.1/README.md +54 -0
- moles_tools-0.0.1/pyproject.toml +99 -0
- moles_tools-0.0.1/src/moles_tools/__init__.py +9 -0
- moles_tools-0.0.1/src/moles_tools/__main__.py +20 -0
- moles_tools-0.0.1/src/moles_tools/env_updater.py +302 -0
- moles_tools-0.0.1/tests/__init__.py +1 -0
- moles_tools-0.0.1/tests/files/env.example +16 -0
- moles_tools-0.0.1/tests/files/env.update +5 -0
- moles_tools-0.0.1/tests/test_env_updater.py +423 -0
|
@@ -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
|
+
[](https://github.com/the78mole/moles-tools/actions/workflows/ci.yml)
|
|
36
|
+
[](https://the78mole.github.io/moles-tools)
|
|
37
|
+
[](https://badge.fury.io/py/moles-tools)
|
|
38
|
+
[](https://www.python.org/downloads/)
|
|
39
|
+
[](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
|
+
[](https://github.com/the78mole/moles-tools/actions/workflows/ci.yml)
|
|
4
|
+
[](https://the78mole.github.io/moles-tools)
|
|
5
|
+
[](https://badge.fury.io/py/moles-tools)
|
|
6
|
+
[](https://www.python.org/downloads/)
|
|
7
|
+
[](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,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,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 == ""
|