pydantic-env-manager 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ *.lcov
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ cover/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ .pybuilder/
77
+ target/
78
+
79
+ # Jupyter Notebook
80
+ .ipynb_checkpoints
81
+
82
+ # IPython
83
+ profile_default/
84
+ ipython_config.py
85
+
86
+ # pyenv
87
+ # For a library or package, you might want to ignore these files since the code is
88
+ # intended to run in multiple environments; otherwise, check them in:
89
+ # .python-version
90
+
91
+ # pipenv
92
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
93
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
94
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
95
+ # install all needed dependencies.
96
+ # Pipfile.lock
97
+
98
+ # uv
99
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
100
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
101
+ # commonly ignored for libraries.
102
+ # uv.lock
103
+
104
+ # poetry
105
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
106
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
107
+ # commonly ignored for libraries.
108
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
109
+ # poetry.lock
110
+ # poetry.toml
111
+
112
+ # pdm
113
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
114
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
115
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
116
+ # pdm.lock
117
+ # pdm.toml
118
+ .pdm-python
119
+ .pdm-build/
120
+
121
+ # pixi
122
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
123
+ # pixi.lock
124
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
125
+ # in the .venv directory. It is recommended not to include this directory in version control.
126
+ .pixi/*
127
+ !.pixi/config.toml
128
+
129
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
130
+ __pypackages__/
131
+
132
+ # Celery stuff
133
+ celerybeat-schedule*
134
+ celerybeat.pid
135
+
136
+ # Redis
137
+ *.rdb
138
+ *.aof
139
+ *.pid
140
+
141
+ # RabbitMQ
142
+ mnesia/
143
+ rabbitmq/
144
+ rabbitmq-data/
145
+
146
+ # ActiveMQ
147
+ activemq-data/
148
+
149
+ # SageMath parsed files
150
+ *.sage.py
151
+
152
+ # Environments
153
+ .env
154
+ .envrc
155
+ .venv
156
+ env/
157
+ venv/
158
+ ENV/
159
+ env.bak/
160
+ venv.bak/
161
+
162
+ # Spyder project settings
163
+ .spyderproject
164
+ .spyproject
165
+
166
+ # Rope project settings
167
+ .ropeproject
168
+
169
+ # mkdocs/Zensical documentation
170
+ /site
171
+
172
+ # mypy
173
+ .mypy_cache/
174
+ .dmypy.json
175
+ dmypy.json
176
+
177
+ # Pyre type checker
178
+ .pyre/
179
+
180
+ # pytype static type analyzer
181
+ .pytype/
182
+
183
+ # Cython debug symbols
184
+ cython_debug/
185
+
186
+ # PyCharm
187
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
188
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
189
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
190
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
191
+ # .idea/
192
+
193
+ # Abstra
194
+ # Abstra is an AI-powered process automation framework.
195
+ # Ignore directories containing user credentials, local state, and settings.
196
+ # Learn more at https://abstra.io/docs
197
+ .abstra/
198
+
199
+ # Visual Studio Code
200
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore that
201
+ # can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
202
+ # and can be added to the global gitignore or merged into this file. However, if you prefer, you
203
+ # could uncomment the following to ignore the entire vscode folder
204
+ # .vscode/
205
+ # Temporary file for partial code execution
206
+ tempCodeRunnerFile.py
207
+
208
+ # Ruff stuff:
209
+ .ruff_cache/
210
+
211
+ # PyPI configuration file
212
+ .pypirc
213
+
214
+ # Marimo
215
+ marimo/_static/
216
+ marimo/_lsp/
217
+ __marimo__/
218
+
219
+ # Streamlit
220
+ .streamlit/secrets.toml
221
+
222
+ # Local scratch directory
223
+ .local/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yashu Ranparia
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,132 @@
1
+ Metadata-Version: 2.5
2
+ Name: pydantic-env-manager
3
+ Version: 0.1.0
4
+ Summary: Manage .env and .env.example for FastAPI/Pydantic apps: declare variables once in your Settings class; files stay complete and agents can check them without reading secrets.
5
+ Project-URL: Homepage, https://github.com/YashuRanparia/pydantic-env-manager
6
+ Project-URL: Issues, https://github.com/YashuRanparia/pydantic-env-manager/issues
7
+ Author: Yashu Ranparia
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: cli,dotenv,env,fastapi,pydantic,pydantic-settings,settings
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: pydantic-settings>=2
22
+ Provides-Extra: dev
23
+ Requires-Dist: pytest; extra == 'dev'
24
+ Requires-Dist: ruff; extra == 'dev'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pydantic-env-manager
28
+
29
+ **Stop hand-editing `.env.example`. Your `Settings` class already knows every variable. Let it write the files.**
30
+
31
+ ```bash
32
+ pip install pydantic-env-manager
33
+ pydantic-env-manager sync app.settings:Settings
34
+ ```
35
+
36
+ That's the whole setup. Declare each variable once, in your Pydantic v2 `BaseSettings` class, and `.env.example` and `.env` follow.
37
+
38
+ ## Sound familiar?
39
+
40
+ - A teammate adds `STRIPE_WEBHOOK_SECRET` to the code and forgets `.env.example`. You find out when staging crashes.
41
+ - A new hire clones the repo, copies `.env.example`, and spends an hour finding which variables are missing.
42
+ - `.env.example` says `PORT=3000`. The code says `8000`. Nobody knows which is right.
43
+ - You don't want a tool that rewrites your `.env` and wipes the values you tuned.
44
+
45
+ `pydantic-env-manager` fixes this, and it's safe to run on a real project.
46
+
47
+ ## What you get
48
+
49
+ | File | What happens |
50
+ |---|---|
51
+ | `.env.example` | Regenerated from `Settings` on every run, with type, default and required-ness for each variable. It never holds a secret. |
52
+ | `.env` | Merge-only. Missing keys are appended. Your values, comments and order stay exactly as you left them. Nothing is deleted. |
53
+
54
+ ## See it work
55
+
56
+ ```python
57
+ # app/settings.py
58
+ from pydantic import Field, SecretStr
59
+ from pydantic_settings import BaseSettings, SettingsConfigDict
60
+
61
+
62
+ class Settings(BaseSettings):
63
+ model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")
64
+
65
+ port: int = Field(8000, description="HTTP port")
66
+ debug: bool = False
67
+ api_key: SecretStr # required, no default
68
+ ```
69
+
70
+ ```bash
71
+ pydantic-env-manager sync app.settings:Settings # write .env.example, merge missing keys into .env
72
+ pydantic-env-manager check app.settings:Settings # write nothing; exit 1 if files have drifted (CI-friendly)
73
+ ```
74
+
75
+ Your generated `.env.example`:
76
+
77
+ ```
78
+ # APP_PORT (int, default: 8000) - HTTP port
79
+ APP_PORT=8000
80
+
81
+ # APP_DEBUG (bool, default: false)
82
+ APP_DEBUG=false
83
+
84
+ # APP_API_KEY (SecretStr, secret, required)
85
+ APP_API_KEY=
86
+ ```
87
+
88
+ Your existing `.env` keeps `APP_PORT=9000` and every comment you wrote. Only the keys it was missing are added.
89
+
90
+ ## Why you can trust it with your `.env`
91
+
92
+ - **Your `.env` is never overwritten.** Existing values, comments and order are preserved. Unrecognised keys are kept too.
93
+ - **Secrets are never written.** `SecretStr` and `SecretBytes` values stay blank in every file.
94
+ - **Running it twice changes nothing.** Unchanged files aren't rewritten, and writes are atomic, so a crash can't leave a half-written file.
95
+ - **One dependency.** It needs only `pydantic-settings`. The CLI uses the standard library.
96
+
97
+ ## Catch drift before it ships
98
+
99
+ `check` writes nothing and exits `1` if `.env.example` is stale or `.env` is missing keys. Add it to CI and a forgotten variable fails the build instead of production:
100
+
101
+ ```bash
102
+ pydantic-env-manager check app.settings:Settings
103
+ ```
104
+
105
+ It also works as a pre-commit hook. See the [usage guide](https://github.com/YashuRanparia/pydantic-env-manager/blob/main/docs/usage.md).
106
+
107
+ ## Prefer to stay in Python?
108
+
109
+ ```python
110
+ from pydantic_env_manager import sync_env, check_env
111
+
112
+ sync_env(Settings) # SyncResult(example_changed, env_changed)
113
+ check_env(Settings) # CheckReport(problems, notes, missing)
114
+ ```
115
+
116
+ For local dev, subclass `EnvManagedSettings` and set `env_sync_on_init = True`. Both files then sync whenever your settings load. It's opt-in and skipped when `CI` or `PROD` is set. If a write fails, it logs a warning and your app still starts.
117
+
118
+ ## Great with AI coding agents
119
+
120
+ Deny your agent access to `.env` and point it at `.env.example`. That file always matches your code and lists every variable with its type, default and required-ness. It holds no secret values, so the agent gets the full picture and none of your credentials.
121
+
122
+ ## Try it in two minutes
123
+
124
+ 1. `pip install pydantic-env-manager`
125
+ 2. Run `pydantic-env-manager sync your_module:Settings`
126
+ 3. Open `.env.example` to see your variables. Your `.env` only gains missing keys; none of your existing values change.
127
+
128
+ Requires Python ≥ 3.9 and `pydantic-settings` ≥ 2. Full details are in the [usage guide](https://github.com/YashuRanparia/pydantic-env-manager/blob/main/docs/usage.md).
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,106 @@
1
+ # pydantic-env-manager
2
+
3
+ **Stop hand-editing `.env.example`. Your `Settings` class already knows every variable. Let it write the files.**
4
+
5
+ ```bash
6
+ pip install pydantic-env-manager
7
+ pydantic-env-manager sync app.settings:Settings
8
+ ```
9
+
10
+ That's the whole setup. Declare each variable once, in your Pydantic v2 `BaseSettings` class, and `.env.example` and `.env` follow.
11
+
12
+ ## Sound familiar?
13
+
14
+ - A teammate adds `STRIPE_WEBHOOK_SECRET` to the code and forgets `.env.example`. You find out when staging crashes.
15
+ - A new hire clones the repo, copies `.env.example`, and spends an hour finding which variables are missing.
16
+ - `.env.example` says `PORT=3000`. The code says `8000`. Nobody knows which is right.
17
+ - You don't want a tool that rewrites your `.env` and wipes the values you tuned.
18
+
19
+ `pydantic-env-manager` fixes this, and it's safe to run on a real project.
20
+
21
+ ## What you get
22
+
23
+ | File | What happens |
24
+ |---|---|
25
+ | `.env.example` | Regenerated from `Settings` on every run, with type, default and required-ness for each variable. It never holds a secret. |
26
+ | `.env` | Merge-only. Missing keys are appended. Your values, comments and order stay exactly as you left them. Nothing is deleted. |
27
+
28
+ ## See it work
29
+
30
+ ```python
31
+ # app/settings.py
32
+ from pydantic import Field, SecretStr
33
+ from pydantic_settings import BaseSettings, SettingsConfigDict
34
+
35
+
36
+ class Settings(BaseSettings):
37
+ model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")
38
+
39
+ port: int = Field(8000, description="HTTP port")
40
+ debug: bool = False
41
+ api_key: SecretStr # required, no default
42
+ ```
43
+
44
+ ```bash
45
+ pydantic-env-manager sync app.settings:Settings # write .env.example, merge missing keys into .env
46
+ pydantic-env-manager check app.settings:Settings # write nothing; exit 1 if files have drifted (CI-friendly)
47
+ ```
48
+
49
+ Your generated `.env.example`:
50
+
51
+ ```
52
+ # APP_PORT (int, default: 8000) - HTTP port
53
+ APP_PORT=8000
54
+
55
+ # APP_DEBUG (bool, default: false)
56
+ APP_DEBUG=false
57
+
58
+ # APP_API_KEY (SecretStr, secret, required)
59
+ APP_API_KEY=
60
+ ```
61
+
62
+ Your existing `.env` keeps `APP_PORT=9000` and every comment you wrote. Only the keys it was missing are added.
63
+
64
+ ## Why you can trust it with your `.env`
65
+
66
+ - **Your `.env` is never overwritten.** Existing values, comments and order are preserved. Unrecognised keys are kept too.
67
+ - **Secrets are never written.** `SecretStr` and `SecretBytes` values stay blank in every file.
68
+ - **Running it twice changes nothing.** Unchanged files aren't rewritten, and writes are atomic, so a crash can't leave a half-written file.
69
+ - **One dependency.** It needs only `pydantic-settings`. The CLI uses the standard library.
70
+
71
+ ## Catch drift before it ships
72
+
73
+ `check` writes nothing and exits `1` if `.env.example` is stale or `.env` is missing keys. Add it to CI and a forgotten variable fails the build instead of production:
74
+
75
+ ```bash
76
+ pydantic-env-manager check app.settings:Settings
77
+ ```
78
+
79
+ It also works as a pre-commit hook. See the [usage guide](https://github.com/YashuRanparia/pydantic-env-manager/blob/main/docs/usage.md).
80
+
81
+ ## Prefer to stay in Python?
82
+
83
+ ```python
84
+ from pydantic_env_manager import sync_env, check_env
85
+
86
+ sync_env(Settings) # SyncResult(example_changed, env_changed)
87
+ check_env(Settings) # CheckReport(problems, notes, missing)
88
+ ```
89
+
90
+ For local dev, subclass `EnvManagedSettings` and set `env_sync_on_init = True`. Both files then sync whenever your settings load. It's opt-in and skipped when `CI` or `PROD` is set. If a write fails, it logs a warning and your app still starts.
91
+
92
+ ## Great with AI coding agents
93
+
94
+ Deny your agent access to `.env` and point it at `.env.example`. That file always matches your code and lists every variable with its type, default and required-ness. It holds no secret values, so the agent gets the full picture and none of your credentials.
95
+
96
+ ## Try it in two minutes
97
+
98
+ 1. `pip install pydantic-env-manager`
99
+ 2. Run `pydantic-env-manager sync your_module:Settings`
100
+ 3. Open `.env.example` to see your variables. Your `.env` only gains missing keys; none of your existing values change.
101
+
102
+ Requires Python ≥ 3.9 and `pydantic-settings` ≥ 2. Full details are in the [usage guide](https://github.com/YashuRanparia/pydantic-env-manager/blob/main/docs/usage.md).
103
+
104
+ ## License
105
+
106
+ MIT
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pydantic-env-manager"
7
+ version = "0.1.0"
8
+ description = "Manage .env and .env.example for FastAPI/Pydantic apps: declare variables once in your Settings class; files stay complete and agents can check them without reading secrets."
9
+ readme = "README.md"
10
+ keywords = ["pydantic", "pydantic-settings", "fastapi", "dotenv", "env", "settings", "cli"]
11
+ requires-python = ">=3.9"
12
+ license = "MIT"
13
+ authors = [{ name = "Yashu Ranparia" }]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = ["pydantic-settings>=2"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/YashuRanparia/pydantic-env-manager"
29
+ Issues = "https://github.com/YashuRanparia/pydantic-env-manager/issues"
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest", "ruff"]
33
+
34
+ [project.scripts]
35
+ pydantic-env-manager = "pydantic_env_manager.cli:main"
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/pydantic_env_manager"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+
46
+ [tool.ruff]
47
+ target-version = "py39"
48
+ line-length = 120
49
+ src = ["src", "tests"]
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "W", "I"]
@@ -0,0 +1,4 @@
1
+ from .sync import CheckReport, EnvManagedSettings, SyncResult, check_env, sync_env
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["CheckReport", "EnvManagedSettings", "SyncResult", "check_env", "sync_env"]
@@ -0,0 +1,53 @@
1
+ """Command line interface: `pydantic-env-manager sync|check module:Class`."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import importlib
6
+ import os
7
+ import sys
8
+ from typing import List, Optional
9
+
10
+ from pydantic_settings import BaseSettings
11
+
12
+ from .sync import check_env, sync_env
13
+
14
+
15
+ def _load(target: str) -> type:
16
+ module, _, name = target.partition(":")
17
+ if not module or not name:
18
+ raise SystemExit(f"error: expected 'module:Class', got {target!r}")
19
+ sys.path.insert(0, os.getcwd()) # console scripts don't have cwd on sys.path
20
+ try:
21
+ cls = getattr(importlib.import_module(module), name)
22
+ except (ImportError, AttributeError) as exc:
23
+ raise SystemExit(f"error: cannot load {target}: {exc}")
24
+ if not (isinstance(cls, type) and issubclass(cls, BaseSettings)):
25
+ raise SystemExit(f"error: {target} is not a pydantic-settings BaseSettings subclass")
26
+ return cls
27
+
28
+
29
+ def main(argv: Optional[List[str]] = None) -> int:
30
+ p = argparse.ArgumentParser(prog="pydantic-env-manager", description=__doc__)
31
+ sub = p.add_subparsers(dest="command", required=True)
32
+ for name, help_ in (("sync", "write .env.example and merge into .env"), ("check", "exit 1 if files have drifted")):
33
+ s = sub.add_parser(name, help=help_)
34
+ s.add_argument("target", help="import path, e.g. app.settings:Settings")
35
+ s.add_argument("--env", help="default: the class's env_file, else .env")
36
+ s.add_argument("--example", help="default: .env.example next to the env file")
37
+ args = p.parse_args(argv)
38
+ cls = _load(args.target)
39
+
40
+ if args.command == "sync":
41
+ res = sync_env(cls, args.env, args.example)
42
+ print("updated" if any(res) else "already in sync")
43
+ return 0
44
+ report = check_env(cls, args.env, args.example)
45
+ for line in report.notes:
46
+ print(f"note: {line}")
47
+ for line in report.problems:
48
+ print(f"drift: {line}", file=sys.stderr)
49
+ return 1 if report.problems else 0
50
+
51
+
52
+ if __name__ == "__main__":
53
+ sys.exit(main())
@@ -0,0 +1,110 @@
1
+ """Line-preserving merge of VarSpecs into an existing .env file."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import List, Optional, Set, Tuple
6
+
7
+ from .render import env_line
8
+ from .schema import VarSpec
9
+
10
+ EXTRAS_HEADING = "# Your additional env variables"
11
+ _KEY = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][\w.-]*)\s*=(.*)$", re.S)
12
+
13
+ # item: (key or None, lines, leading_comment_lines)
14
+ Item = Tuple[Optional[str], List[str], List[str]]
15
+
16
+
17
+ def _closes(rest: str, quote: str) -> bool:
18
+ """True if `rest` contains the closing quote for a value that opened with `quote`."""
19
+ i = 0
20
+ while i < len(rest):
21
+ if quote == '"' and rest[i] == "\\":
22
+ i += 2
23
+ continue
24
+ if rest[i] == quote:
25
+ return True
26
+ i += 1
27
+ return False
28
+
29
+
30
+ def _parse(text: str) -> List[Item]:
31
+ lines = re.findall(r"[^\n]*\n|[^\n]+", text)
32
+ items: List[Item] = []
33
+ comments: List[str] = []
34
+ i = 0
35
+ while i < len(lines):
36
+ line = lines[i]
37
+ m = _KEY.match(line.rstrip("\r\n"))
38
+ if m:
39
+ chunk = [line]
40
+ value = m.group(2).lstrip()
41
+ if value[:1] in ("'", '"') and not _closes(value[1:], value[0]):
42
+ while i + 1 < len(lines): # multi-line quoted value
43
+ i += 1
44
+ chunk.append(lines[i])
45
+ if _closes(lines[i], value[0]):
46
+ break
47
+ items.append((m.group(1), chunk, comments))
48
+ comments = []
49
+ elif line.lstrip().startswith("#"):
50
+ comments.append(line)
51
+ else: # blank or unparseable: keep verbatim
52
+ items.append((None, comments + [line], []))
53
+ comments = []
54
+ i += 1
55
+ if comments:
56
+ items.append((None, comments, []))
57
+ return items
58
+
59
+
60
+ def _norm(key: str, case_sensitive: bool) -> str:
61
+ return key if case_sensitive else key.upper()
62
+
63
+
64
+ def keys_in(text: str, case_sensitive: bool = False) -> Set[str]:
65
+ return {_norm(k, case_sensitive) for k, _, _ in _parse(text) if k}
66
+
67
+
68
+ def merge(text: str, specs: List[VarSpec], case_sensitive: bool = False) -> str:
69
+ """Append missing keys and move unknown keys under a trailing heading.
70
+
71
+ Existing values, comments and order of known keys are untouched.
72
+ """
73
+ eol = "\r\n" if "\r\n" in text else "\n"
74
+ known = {s.key for s in specs}
75
+ kept: List[str] = []
76
+ extras: List[str] = []
77
+ present: Set[str] = set()
78
+ for key, lines, comments in _parse(text):
79
+ if key is None:
80
+ kept += lines
81
+ elif _norm(key, case_sensitive) in known:
82
+ present.add(_norm(key, case_sensitive))
83
+ kept += comments + lines
84
+ else:
85
+ extras += [c for c in comments if c.strip() != EXTRAS_HEADING] + lines
86
+
87
+ def nl(lines: List[str]) -> List[str]:
88
+ return [x if x.endswith("\n") else x + eol for x in lines]
89
+
90
+ kept, extras = nl(kept), nl(extras)
91
+ missing = [s for s in specs if s.key not in present]
92
+ if not missing and not extras:
93
+ return text
94
+ while kept and not kept[-1].strip(): # trailing blanks are regenerated below
95
+ kept.pop()
96
+ out = kept
97
+ if missing:
98
+ block: List[str] = []
99
+ for s in missing:
100
+ block += ([f"# REQUIRED{eol}"] if s.required else []) + [env_line(s) + eol]
101
+ out += ([eol] if out else []) + block
102
+ if extras:
103
+ out += ([eol] if out else []) + [EXTRAS_HEADING + eol] + extras
104
+ return "".join(out)
105
+
106
+
107
+ def missing_and_extra(text: str, specs: List[VarSpec], case_sensitive: bool = False) -> Tuple[List[str], List[str]]:
108
+ have = keys_in(text, case_sensitive)
109
+ known = {s.key for s in specs}
110
+ return [s.key for s in specs if s.key not in have], sorted(have - known)
@@ -0,0 +1,38 @@
1
+ """Render .env-style text from VarSpecs."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import List
6
+
7
+ from .schema import VarSpec
8
+
9
+ _PLAIN = re.compile(r"^[\w./:@%+,=-]*$")
10
+
11
+
12
+ def quote(value: str) -> str:
13
+ """Quote a value only when .env parsers would otherwise misread it."""
14
+ if _PLAIN.match(value):
15
+ return value
16
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") + '"'
17
+
18
+
19
+ def env_line(spec: VarSpec) -> str:
20
+ return f"{spec.key}={quote(spec.default)}"
21
+
22
+
23
+ def annotation(spec: VarSpec) -> str:
24
+ parts = [spec.type]
25
+ if spec.secret:
26
+ parts.append("secret")
27
+ if spec.required:
28
+ parts.append("required")
29
+ elif not spec.secret:
30
+ parts.append(f"default: {spec.default or None}")
31
+ text = f"# {spec.key} ({', '.join(parts)})"
32
+ return f"{text} - {spec.description}" if spec.description else text
33
+
34
+
35
+ def render_example(specs: List[VarSpec], source: str, eol: str = "\n") -> str:
36
+ blocks = [f"# Generated by pydantic-env-manager from {source}. Do not edit by hand."]
37
+ blocks += [annotation(s) + eol + env_line(s) for s in specs]
38
+ return (eol + eol).join(blocks) + eol
@@ -0,0 +1,75 @@
1
+ """Introspect a BaseSettings class into a flat list of env var specs."""
2
+ from __future__ import annotations
3
+
4
+ import enum
5
+ import json
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from typing import Any, List, Optional, Type, get_args
9
+
10
+ from pydantic import AliasChoices, AliasPath, SecretBytes, SecretStr
11
+ from pydantic_settings import BaseSettings
12
+
13
+ log = logging.getLogger(__name__)
14
+ _SECRETS = (SecretStr, SecretBytes)
15
+
16
+
17
+ @dataclass
18
+ class VarSpec:
19
+ key: str
20
+ type: str
21
+ default: str # rendered default; "" when none (or secret)
22
+ required: bool
23
+ secret: bool
24
+ description: str
25
+
26
+
27
+ def resolve_key(cls: Type[BaseSettings], name: str, field: Any) -> Optional[str]:
28
+ """Env var name for a field, or None if it can't be expressed as one."""
29
+ cfg = cls.model_config
30
+ alias = field.validation_alias
31
+ if isinstance(alias, AliasChoices):
32
+ alias = alias.choices[0]
33
+ if isinstance(alias, AliasPath):
34
+ log.warning("skipping %s.%s: AliasPath is not supported", cls.__name__, name)
35
+ return None
36
+ # pydantic-settings does not apply env_prefix to aliased fields
37
+ key = alias if alias else (cfg.get("env_prefix") or "") + name
38
+ return key if cfg.get("case_sensitive") else key.upper()
39
+
40
+
41
+ def _is_secret(annotation: Any) -> bool:
42
+ if isinstance(annotation, type) and issubclass(annotation, _SECRETS):
43
+ return True
44
+ return any(_is_secret(a) for a in get_args(annotation))
45
+
46
+
47
+ def _type_name(annotation: Any) -> str:
48
+ if isinstance(annotation, type) and not get_args(annotation):
49
+ return annotation.__name__
50
+ return str(annotation).replace("typing.", "")
51
+
52
+
53
+ def _render_default(value: Any) -> str:
54
+ if value is None:
55
+ return ""
56
+ if isinstance(value, enum.Enum):
57
+ value = value.value
58
+ if isinstance(value, bool):
59
+ return str(value).lower()
60
+ if isinstance(value, (list, tuple, set, dict)):
61
+ return json.dumps(sorted(value) if isinstance(value, set) else value, default=str)
62
+ return str(value)
63
+
64
+
65
+ def collect(cls: Type[BaseSettings]) -> List[VarSpec]:
66
+ specs = []
67
+ for name, field in cls.model_fields.items():
68
+ key = resolve_key(cls, name, field)
69
+ if key is None:
70
+ continue
71
+ secret = _is_secret(field.annotation)
72
+ required = field.is_required()
73
+ default = "" if secret or required else _render_default(field.get_default(call_default_factory=True))
74
+ specs.append(VarSpec(key, _type_name(field.annotation), default, required, secret, field.description or ""))
75
+ return specs
@@ -0,0 +1,128 @@
1
+ """Public API: sync_env, check_env and the EnvManagedSettings boot hook."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Any, ClassVar, List, NamedTuple, Optional, Tuple, Type, Union
9
+
10
+ from pydantic_settings import BaseSettings
11
+
12
+ from . import envfile
13
+ from .render import render_example
14
+ from .schema import collect
15
+
16
+ log = logging.getLogger(__name__)
17
+ PathLike = Union[str, "os.PathLike[str]"]
18
+
19
+
20
+ class SyncResult(NamedTuple):
21
+ example_changed: bool
22
+ env_changed: bool
23
+
24
+
25
+ class CheckReport(NamedTuple):
26
+ problems: List[str] # fail the check
27
+ notes: List[str] # informational only
28
+ missing: List[str] # env keys declared in Settings but absent from .env
29
+
30
+
31
+ def _paths(
32
+ cls: Type[BaseSettings], env_file: Optional[PathLike], example_file: Optional[PathLike]
33
+ ) -> Tuple[Path, Path]:
34
+ if env_file is None:
35
+ cfg = cls.model_config.get("env_file")
36
+ env_file = (cfg[0] if isinstance(cfg, (list, tuple)) else cfg) or ".env"
37
+ env_path = Path(env_file)
38
+ return env_path, Path(example_file) if example_file else env_path.with_name(".env.example")
39
+
40
+
41
+ def _read(path: Path) -> Optional[str]:
42
+ if not path.exists():
43
+ return None
44
+ with open(path, encoding="utf-8", newline="") as f:
45
+ return f.read()
46
+
47
+
48
+ def _write(path: Path, text: str, private: bool) -> bool:
49
+ """Atomically write `text`; returns False (and touches nothing) if unchanged."""
50
+ if _read(path) == text:
51
+ return False
52
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".")
53
+ try:
54
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
55
+ f.write(text)
56
+ if path.exists():
57
+ os.chmod(tmp, path.stat().st_mode & 0o777)
58
+ elif not private:
59
+ os.chmod(tmp, 0o644)
60
+ os.replace(tmp, path)
61
+ except BaseException:
62
+ os.unlink(tmp)
63
+ raise
64
+ return True
65
+
66
+
67
+ def _example_text(cls: Type[BaseSettings], example: Path) -> str:
68
+ old = _read(example) or ""
69
+ eol = "\r\n" if "\r\n" in old else "\n"
70
+ return render_example(collect(cls), f"{cls.__module__}.{cls.__qualname__}", eol)
71
+
72
+
73
+ def sync_env(
74
+ cls: Type[BaseSettings], env_file: Optional[PathLike] = None, example_file: Optional[PathLike] = None
75
+ ) -> SyncResult:
76
+ """Regenerate .env.example and merge missing keys into .env."""
77
+ env, example = _paths(cls, env_file, example_file)
78
+ cs = bool(cls.model_config.get("case_sensitive"))
79
+ merged = envfile.merge(_read(env) or "", collect(cls), cs)
80
+ return SyncResult(
81
+ _write(example, _example_text(cls, example), private=False),
82
+ _write(env, merged, private=True),
83
+ )
84
+
85
+
86
+ def check_env(
87
+ cls: Type[BaseSettings], env_file: Optional[PathLike] = None, example_file: Optional[PathLike] = None
88
+ ) -> CheckReport:
89
+ """Report drift without writing anything. A missing .env is skipped (e.g. in CI)."""
90
+ env, example = _paths(cls, env_file, example_file)
91
+ problems: List[str] = []
92
+ notes: List[str] = []
93
+ missing: List[str] = []
94
+ if _read(example) != _example_text(cls, example):
95
+ problems.append(f"{example} is out of date")
96
+ env_text = _read(env)
97
+ if env_text is None:
98
+ notes.append(f"{env} not found, skipped")
99
+ else:
100
+ missing, extra = envfile.missing_and_extra(
101
+ env_text, collect(cls), bool(cls.model_config.get("case_sensitive"))
102
+ )
103
+ if missing:
104
+ problems.append(f"{env} is missing: {', '.join(missing)}")
105
+ if extra:
106
+ notes.append(f"{env} has keys not in settings: {', '.join(extra)}")
107
+ return CheckReport(problems, notes, missing)
108
+
109
+
110
+ def _skip_boot_sync() -> bool:
111
+ return any(os.environ.get(v, "").lower() not in ("", "0", "false") for v in ("CI", "PROD"))
112
+
113
+
114
+ class EnvManagedSettings(BaseSettings):
115
+ """BaseSettings that syncs .env / .env.example on creation when `env_sync_on_init = True`.
116
+
117
+ Dev convenience only: skipped when CI or PROD is set; failures only warn.
118
+ """
119
+
120
+ env_sync_on_init: ClassVar[bool] = False
121
+
122
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
123
+ super().__init__(*args, **kwargs)
124
+ if type(self).env_sync_on_init and not _skip_boot_sync():
125
+ try:
126
+ sync_env(type(self))
127
+ except Exception as exc: # never break the app over a dev convenience
128
+ log.warning("pydantic-env-manager: sync failed: %s", exc)
@@ -0,0 +1,360 @@
1
+ import logging
2
+ from typing import ClassVar
3
+
4
+ import pytest
5
+ from pydantic import AliasChoices, AliasPath, Field, SecretStr
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+ from pydantic_env_manager import EnvManagedSettings, check_env, sync_env
9
+ from pydantic_env_manager.cli import main
10
+ from pydantic_env_manager.envfile import EXTRAS_HEADING, merge
11
+ from pydantic_env_manager.schema import collect
12
+
13
+
14
+ class S(BaseSettings):
15
+ model_config = SettingsConfigDict(env_prefix="APP_")
16
+ port: int = Field(8000, description="HTTP port")
17
+ debug: bool = False
18
+ name: str # required
19
+ token: SecretStr = SecretStr("hunter2")
20
+ url: str = Field("x", validation_alias="DB_URL") # alias bypasses prefix
21
+ multi: str = Field("a", validation_alias=AliasChoices("FIRST", "second"))
22
+ skipped: str = Field("a", validation_alias=AliasPath("a", 0))
23
+
24
+
25
+ def by_key():
26
+ return {s.key: s for s in collect(S)}
27
+
28
+
29
+ def test_key_resolution():
30
+ specs = by_key()
31
+ assert set(specs) == {"APP_PORT", "APP_DEBUG", "APP_NAME", "APP_TOKEN", "DB_URL", "FIRST"}
32
+
33
+
34
+ def test_case_sensitive():
35
+ class C(BaseSettings):
36
+ model_config = SettingsConfigDict(case_sensitive=True)
37
+ Mixed: int = 1
38
+
39
+ assert [s.key for s in collect(C)] == ["Mixed"]
40
+
41
+
42
+ def test_classvar_constants_are_not_env_vars():
43
+ class C(BaseSettings):
44
+ name: str = "x"
45
+ MAX_RETRIES: ClassVar[int] = 3
46
+
47
+ assert [s.key for s in collect(C)] == ["NAME"]
48
+ assert C().MAX_RETRIES == 3
49
+
50
+
51
+ def test_spec_values():
52
+ s = by_key()
53
+ assert (s["APP_PORT"].default, s["APP_DEBUG"].default) == ("8000", "false")
54
+ assert s["APP_NAME"].required and s["APP_NAME"].default == ""
55
+ assert s["APP_TOKEN"].secret and s["APP_TOKEN"].default == "" # secret never leaks
56
+
57
+
58
+ def test_sync_creates_both_files(tmp_path):
59
+ env, ex = tmp_path / ".env", tmp_path / ".env.example"
60
+ assert sync_env(S, env, ex) == (True, True)
61
+ example = ex.read_text()
62
+ assert "# APP_PORT (int, default: 8000) - HTTP port\nAPP_PORT=8000" in example
63
+ assert "# APP_NAME (str, required)\nAPP_NAME=\n" in example
64
+ assert "hunter2" not in example + env.read_text()
65
+ assert "# REQUIRED\nAPP_NAME=\n" in env.read_text()
66
+
67
+
68
+ def test_sync_is_idempotent(tmp_path):
69
+ env, ex = tmp_path / ".env", tmp_path / ".env.example"
70
+ sync_env(S, env, ex)
71
+ assert sync_env(S, env, ex) == (False, False)
72
+
73
+
74
+ def test_env_merge_preserves_and_moves_extras(tmp_path):
75
+ env, ex = tmp_path / ".env", tmp_path / ".env.example"
76
+ env.write_text(
77
+ "# my port\nAPP_PORT=9000 # inline\nOTHER=1\n\n# keep me\nexport APP_NAME='bob'\n"
78
+ 'MULTI="line1\nline2"\n# about extra\nEXTRA=2\n'
79
+ )
80
+ sync_env(S, env, ex)
81
+ out = env.read_text()
82
+ assert out.startswith("# my port\nAPP_PORT=9000 # inline\n") # untouched, in order
83
+ assert "# keep me\nexport APP_NAME='bob'\n" in out
84
+ assert out.index("APP_DEBUG=false") < out.index(EXTRAS_HEADING) # missing keys before extras
85
+ tail = out[out.index(EXTRAS_HEADING):]
86
+ assert tail == f'{EXTRAS_HEADING}\nOTHER=1\nMULTI="line1\nline2"\n# about extra\nEXTRA=2\n'
87
+ assert out.count(EXTRAS_HEADING) == 1
88
+ assert sync_env(S, env, ex).env_changed is False
89
+
90
+
91
+ def test_merge_noop_returns_text_untouched():
92
+ text = "APP_PORT=1\n\n\n"
93
+ specs = [s for s in collect(S) if s.key == "APP_PORT"]
94
+ assert merge(text, specs) == text
95
+
96
+
97
+ def test_merge_case_insensitive_and_crlf():
98
+ specs = [s for s in collect(S) if s.key in ("APP_PORT", "APP_DEBUG")]
99
+ out = merge("app_port=1\r\n", specs)
100
+ assert out == "app_port=1\r\n\r\nAPP_DEBUG=false\r\n"
101
+
102
+
103
+ def test_no_trailing_newline():
104
+ specs = [s for s in collect(S) if s.key in ("APP_PORT", "APP_DEBUG")]
105
+ assert merge("APP_PORT=1", specs) == "APP_PORT=1\n\nAPP_DEBUG=false\n"
106
+
107
+
108
+ def test_check(tmp_path):
109
+ env, ex = tmp_path / ".env", tmp_path / ".env.example"
110
+ assert check_env(S, env, ex).problems # nothing generated yet
111
+ sync_env(S, env, ex)
112
+ assert check_env(S, env, ex).problems == []
113
+ env.write_text(env.read_text() + "STRAY=1\n")
114
+ r = check_env(S, env, ex)
115
+ assert r.problems == [] and "STRAY" in r.notes[0] # extras never fail
116
+ env.write_text("APP_PORT=1\n")
117
+ r = check_env(S, env, ex)
118
+ assert "missing" in r.problems[0] and "APP_PORT" not in r.missing and "APP_NAME" in r.missing
119
+ ex.write_text("stale\n")
120
+ assert any("out of date" in p for p in check_env(S, env, ex).problems)
121
+
122
+
123
+ def test_check_skips_absent_env(tmp_path):
124
+ ex = tmp_path / ".env.example"
125
+ sync_env(S, tmp_path / "tmp.env", ex)
126
+ assert check_env(S, tmp_path / ".env", ex).problems == []
127
+
128
+
129
+ def test_check_does_not_write(tmp_path):
130
+ check_env(S, tmp_path / ".env", tmp_path / ".env.example")
131
+ assert list(tmp_path.iterdir()) == []
132
+
133
+
134
+ def test_cli(tmp_path, monkeypatch, capsys):
135
+ monkeypatch.chdir(tmp_path)
136
+ (tmp_path / "cli_settings.py").write_text(
137
+ "from pydantic_settings import BaseSettings\nclass Settings(BaseSettings):\n port: int = 1\n"
138
+ )
139
+ assert main(["check", "cli_settings:Settings"]) == 1
140
+ assert main(["sync", "cli_settings:Settings"]) == 0
141
+ assert main(["check", "cli_settings:Settings"]) == 0
142
+ assert (tmp_path / ".env").exists() and (tmp_path / ".env.example").exists()
143
+ with pytest.raises(SystemExit):
144
+ main(["check", "cli_settings:Nope"])
145
+
146
+
147
+ def _boot_cls(tmp_path):
148
+ class B(EnvManagedSettings):
149
+ model_config = SettingsConfigDict(env_file=str(tmp_path / ".env"))
150
+ env_sync_on_init = True
151
+ port: int = 1
152
+
153
+ return B
154
+
155
+
156
+ @pytest.fixture
157
+ def clean_env(monkeypatch):
158
+ monkeypatch.delenv("CI", raising=False)
159
+ monkeypatch.delenv("PROD", raising=False)
160
+
161
+
162
+ def test_boot_sync_writes(tmp_path, clean_env):
163
+ _boot_cls(tmp_path)()
164
+ assert (tmp_path / ".env").exists() and (tmp_path / ".env.example").exists()
165
+
166
+
167
+ @pytest.mark.parametrize("var", ["CI", "PROD"])
168
+ def test_boot_sync_skipped_in_ci_or_prod(tmp_path, clean_env, monkeypatch, var):
169
+ monkeypatch.setenv(var, "true")
170
+ _boot_cls(tmp_path)()
171
+ assert list(tmp_path.iterdir()) == []
172
+
173
+
174
+ def test_boot_sync_failure_only_warns(tmp_path, clean_env, caplog):
175
+ cls = _boot_cls(tmp_path / "missing_dir")
176
+ with caplog.at_level(logging.WARNING):
177
+ assert cls().port == 1 # app still starts
178
+ assert "sync failed" in caplog.text
179
+
180
+
181
+ def test_boot_sync_off_by_default(tmp_path, clean_env):
182
+ class Off(EnvManagedSettings):
183
+ model_config = SettingsConfigDict(env_file=str(tmp_path / ".env"))
184
+
185
+ Off()
186
+ assert list(tmp_path.iterdir()) == []
187
+
188
+
189
+ # --- edge cases ---------------------------------------------------------------
190
+
191
+
192
+ def test_unchanged_files_not_rewritten(tmp_path):
193
+ env, ex = tmp_path / ".env", tmp_path / ".env.example"
194
+ sync_env(S, env, ex)
195
+ before = (env.stat().st_ino, env.stat().st_mtime_ns, ex.stat().st_ino, ex.stat().st_mtime_ns)
196
+ sync_env(S, env, ex)
197
+ assert before == (env.stat().st_ino, env.stat().st_mtime_ns, ex.stat().st_ino, ex.stat().st_mtime_ns)
198
+
199
+
200
+ def test_write_is_atomic_on_failure(tmp_path, monkeypatch):
201
+ env, ex = tmp_path / ".env", tmp_path / ".env.example"
202
+ env.write_text("APP_PORT=1\n")
203
+ monkeypatch.setattr("pydantic_env_manager.sync.os.replace", lambda *a: (_ for _ in ()).throw(OSError("boom")))
204
+ with pytest.raises(OSError):
205
+ sync_env(S, env, ex)
206
+ assert env.read_text() == "APP_PORT=1\n" # original intact
207
+ assert not ex.exists()
208
+ assert [p.name for p in tmp_path.iterdir()] == [".env"] # no temp files left behind
209
+
210
+
211
+ def test_no_temp_files_after_success(tmp_path):
212
+ sync_env(S, tmp_path / ".env", tmp_path / ".env.example")
213
+ assert sorted(p.name for p in tmp_path.iterdir()) == [".env", ".env.example"]
214
+
215
+
216
+ def test_file_modes(tmp_path):
217
+ sync_env(S, tmp_path / ".env", tmp_path / ".env.example")
218
+ assert (tmp_path / ".env").stat().st_mode & 0o777 == 0o600
219
+ assert (tmp_path / ".env.example").stat().st_mode & 0o777 == 0o644
220
+
221
+
222
+ def test_existing_env_mode_preserved(tmp_path):
223
+ env = tmp_path / ".env"
224
+ env.write_text("X=1\n")
225
+ env.chmod(0o640)
226
+ sync_env(S, env, tmp_path / ".env.example")
227
+ assert env.stat().st_mode & 0o777 == 0o640
228
+
229
+
230
+ @pytest.mark.parametrize(
231
+ "default, expected",
232
+ [
233
+ ("plain", "plain"),
234
+ ("", ""),
235
+ ("has space", '"has space"'),
236
+ ("a#b", '"a#b"'),
237
+ ('say "hi"', '"say \\"hi\\""'),
238
+ ("back\\slash", '"back\\\\slash"'),
239
+ ("two\nlines", '"two\\nlines"'),
240
+ ("$HOME", '"$HOME"'),
241
+ ],
242
+ )
243
+ def test_quote(default, expected):
244
+ from pydantic_env_manager.render import quote
245
+
246
+ assert quote(default) == expected
247
+
248
+
249
+ def test_special_char_defaults_in_example(tmp_path):
250
+ class Q(BaseSettings):
251
+ a: str = "has space # not a comment"
252
+ b: str = 'q"uote'
253
+
254
+ ex = tmp_path / ".env.example"
255
+ sync_env(Q, tmp_path / ".env", ex)
256
+ text = ex.read_text()
257
+ assert 'A="has space # not a comment"\n' in text
258
+ assert 'B="q\\"uote"\n' in text
259
+
260
+
261
+ def test_assorted_field_types(tmp_path):
262
+ import enum
263
+ from typing import Dict, List, Optional
264
+
265
+ class Color(enum.Enum):
266
+ RED = "red"
267
+
268
+ class T(BaseSettings):
269
+ opt: Optional[str] = None
270
+ opt_req: Optional[int]
271
+ items: List[int] = [1, 2]
272
+ mapping: Dict[str, int] = {"a": 1}
273
+ color: Color = Color.RED
274
+ secret_req: SecretStr
275
+
276
+ specs = {s.key: s for s in collect(T)}
277
+ assert set(specs) == {"OPT", "OPT_REQ", "ITEMS", "MAPPING", "COLOR", "SECRET_REQ"}
278
+ assert specs["OPT"].default == ""
279
+ assert specs["OPT_REQ"].required
280
+ assert specs["SECRET_REQ"].required and specs["SECRET_REQ"].default == ""
281
+ ex = tmp_path / ".env.example"
282
+ sync_env(T, tmp_path / ".env", ex)
283
+ assert sync_env(T, tmp_path / ".env", ex) == (False, False) # still idempotent
284
+
285
+
286
+ def test_empty_settings_class(tmp_path):
287
+ class Empty(BaseSettings):
288
+ pass
289
+
290
+ assert collect(Empty) == []
291
+ assert sync_env(Empty, tmp_path / ".env", tmp_path / ".env.example") == (True, True)
292
+ assert sync_env(Empty, tmp_path / ".env", tmp_path / ".env.example") == (False, False)
293
+ assert check_env(Empty, tmp_path / ".env", tmp_path / ".env.example").problems == []
294
+
295
+
296
+ def test_merge_empty_env():
297
+ specs = [s for s in collect(S) if s.key == "APP_PORT"]
298
+ assert merge("", specs) == "APP_PORT=8000\n"
299
+
300
+
301
+ def test_merge_malformed_and_duplicate_lines_kept():
302
+ specs = [s for s in collect(S) if s.key == "APP_PORT"]
303
+ text = "garbage line\nAPP_PORT=1\nAPP_PORT=2\n=novalue\n"
304
+ out = merge(text, specs)
305
+ assert out == text # nothing missing, nothing extra: byte-for-byte
306
+
307
+
308
+ def test_merge_comment_stays_with_moved_extra():
309
+ specs = [s for s in collect(S) if s.key == "APP_PORT"]
310
+ out = merge("# about x\nX=1\nAPP_PORT=1\n", specs)
311
+ assert out == f"APP_PORT=1\n\n{EXTRAS_HEADING}\n# about x\nX=1\n"
312
+
313
+
314
+ def test_merge_never_duplicates_existing_extras_heading():
315
+ specs = [s for s in collect(S) if s.key == "APP_PORT"]
316
+ first = merge("APP_PORT=1\nX=1\n", specs)
317
+ assert merge(first, specs) == first
318
+ assert first.count(EXTRAS_HEADING) == 1
319
+
320
+
321
+ def test_merge_unterminated_quote_does_not_crash():
322
+ specs = [s for s in collect(S) if s.key == "APP_PORT"]
323
+ out = merge('BROKEN="never closed\nAPP_PORT=1\n', specs)
324
+ assert "APP_PORT=1" in out and "BROKEN" in out
325
+
326
+
327
+ def test_merge_value_with_hash_and_equals_untouched():
328
+ specs = [s for s in collect(S) if s.key == "APP_PORT"]
329
+ text = "APP_PORT=a=b#c\n"
330
+ assert merge(text, specs) == text
331
+
332
+
333
+ def test_crlf_example_preserved(tmp_path):
334
+ ex = tmp_path / ".env.example"
335
+ ex.write_bytes(b"old\r\n")
336
+ sync_env(S, tmp_path / ".env", ex)
337
+ data = ex.read_bytes()
338
+ assert b"\r\n" in data and b"\n" not in data.replace(b"\r\n", b"")
339
+
340
+
341
+ def test_check_missing_example_is_a_problem(tmp_path):
342
+ (tmp_path / ".env").write_text("")
343
+ assert any("out of date" in p for p in check_env(S, tmp_path / ".env", tmp_path / ".env.example").problems)
344
+
345
+
346
+ def test_env_file_taken_from_model_config(tmp_path):
347
+ class C(BaseSettings):
348
+ model_config = SettingsConfigDict(env_file=str(tmp_path / "custom.env"))
349
+ a: int = 1
350
+
351
+ sync_env(C)
352
+ assert (tmp_path / "custom.env").exists() and (tmp_path / ".env.example").exists()
353
+
354
+
355
+ def test_cli_bad_target(tmp_path, monkeypatch):
356
+ monkeypatch.chdir(tmp_path)
357
+ with pytest.raises(SystemExit):
358
+ main(["check", "no_colon_here"])
359
+ with pytest.raises(SystemExit):
360
+ main(["check", "does_not_exist_mod:Settings"])