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.
- pydantic_env_manager-0.1.0/.gitignore +223 -0
- pydantic_env_manager-0.1.0/LICENSE +21 -0
- pydantic_env_manager-0.1.0/PKG-INFO +132 -0
- pydantic_env_manager-0.1.0/README.md +106 -0
- pydantic_env_manager-0.1.0/pyproject.toml +52 -0
- pydantic_env_manager-0.1.0/src/pydantic_env_manager/__init__.py +4 -0
- pydantic_env_manager-0.1.0/src/pydantic_env_manager/cli.py +53 -0
- pydantic_env_manager-0.1.0/src/pydantic_env_manager/envfile.py +110 -0
- pydantic_env_manager-0.1.0/src/pydantic_env_manager/py.typed +0 -0
- pydantic_env_manager-0.1.0/src/pydantic_env_manager/render.py +38 -0
- pydantic_env_manager-0.1.0/src/pydantic_env_manager/schema.py +75 -0
- pydantic_env_manager-0.1.0/src/pydantic_env_manager/sync.py +128 -0
- pydantic_env_manager-0.1.0/tests/test_pydantic_env_manager.py +360 -0
|
@@ -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,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)
|
|
File without changes
|
|
@@ -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"])
|