psyplus 1.0.0__tar.gz → 1.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.
- {psyplus-1.0.0 → psyplus-1.1.0}/PKG-INFO +58 -15
- {psyplus-1.0.0 → psyplus-1.1.0}/README.md +57 -14
- psyplus-1.1.0/psyplus/field_info.py +269 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/psyplus/main.py +43 -38
- {psyplus-1.0.0 → psyplus-1.1.0}/psyplus/markdown_generator.py +23 -26
- {psyplus-1.0.0 → psyplus-1.1.0}/psyplus/utils.py +61 -16
- {psyplus-1.0.0 → psyplus-1.1.0}/psyplus/yaml_generator.py +32 -7
- {psyplus-1.0.0 → psyplus-1.1.0}/pyproject.toml +1 -1
- psyplus-1.1.0/tests/models/env_cases.py +98 -0
- psyplus-1.1.0/tests/models/nullable.py +23 -0
- psyplus-1.1.0/tests/test_env_roundtrip.py +207 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/test_field_info.py +44 -2
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/test_main.py +113 -1
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/test_markdown.py +41 -0
- psyplus-1.1.0/tests/test_nullable.py +110 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/test_utils.py +69 -2
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/test_yaml_generation.py +66 -0
- psyplus-1.0.0/psyplus/field_info.py +0 -175
- {psyplus-1.0.0 → psyplus-1.1.0}/LICENSE +0 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/psyplus/__init__.py +0 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/__init__.py +0 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/models/__init__.py +0 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/models/nested.py +0 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/models/readme_example.py +0 -0
- {psyplus-1.0.0 → psyplus-1.1.0}/tests/models/simple.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: psyplus
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Generate, read and document pydantic-settings config files in YAML
|
|
5
5
|
Keywords: DZD,pydantic,pydantic-settings,yaml,config
|
|
6
6
|
Author-Email: Tim Bleimehl <bleimehl@helmholtz-munich.de>
|
|
@@ -148,18 +148,18 @@ The command above creates `config.yaml` with a comment block above every key:
|
|
|
148
148
|
|
|
149
149
|
```yaml
|
|
150
150
|
# ## log_level ###
|
|
151
|
-
# Type: Enum
|
|
151
|
+
# Type: Enum or null
|
|
152
152
|
# Required: False
|
|
153
153
|
# Default: "INFO"
|
|
154
154
|
# Allowed vals: ['INFO', 'DEBUG']
|
|
155
|
-
# Env-var: 'APP_LOG_LEVEL'
|
|
155
|
+
# Env-var: 'APP_LOG_LEVEL' (can not set null, use null in the YAML file)
|
|
156
156
|
log_level: INFO
|
|
157
157
|
|
|
158
158
|
# ## app_name ###
|
|
159
|
-
# Type: str
|
|
159
|
+
# Type: str or null
|
|
160
160
|
# Required: False
|
|
161
161
|
# Default: "THE APP"
|
|
162
|
-
# Env-var: 'APP_APP_NAME'
|
|
162
|
+
# Env-var: 'APP_APP_NAME' (can not set null, use null in the YAML file)
|
|
163
163
|
# Description: The display name of the app
|
|
164
164
|
# Example No. 1:
|
|
165
165
|
# >app_name: THAT APP
|
|
@@ -168,9 +168,9 @@ log_level: INFO
|
|
|
168
168
|
app_name: THE APP
|
|
169
169
|
|
|
170
170
|
# ## storage_dir ###
|
|
171
|
-
# Type: str
|
|
171
|
+
# Type: str or null
|
|
172
172
|
# Required: False
|
|
173
|
-
# Env-var: 'APP_STORAGE_DIR'
|
|
173
|
+
# Env-var: 'APP_STORAGE_DIR' (can not set null, use null in the YAML file)
|
|
174
174
|
# Description: A directory to store files for the app.
|
|
175
175
|
storage_dir: /home/user/.config/myapp
|
|
176
176
|
|
|
@@ -197,19 +197,19 @@ database_server:
|
|
|
197
197
|
|
|
198
198
|
# ## host ###
|
|
199
199
|
# YAML-path: database_server.host
|
|
200
|
-
# Type: str
|
|
200
|
+
# Type: str or null
|
|
201
201
|
# Required: False
|
|
202
202
|
# Default: "localhost"
|
|
203
|
-
# Env-var: 'APP_DATABASE_SERVER__HOST'
|
|
203
|
+
# Env-var: 'APP_DATABASE_SERVER__HOST' (can not set null, use null in the YAML file)
|
|
204
204
|
# Description: The hostname the database will be available at
|
|
205
205
|
host: localhost
|
|
206
206
|
|
|
207
207
|
# ## port ###
|
|
208
208
|
# YAML-path: database_server.port
|
|
209
|
-
# Type: int
|
|
209
|
+
# Type: int or null
|
|
210
210
|
# Required: False
|
|
211
211
|
# Default: 5678
|
|
212
|
-
# Env-var: 'APP_DATABASE_SERVER__PORT'
|
|
212
|
+
# Env-var: 'APP_DATABASE_SERVER__PORT' (can not set null, use null in the YAML file)
|
|
213
213
|
# Description: The port to connect to the database
|
|
214
214
|
port: 5678
|
|
215
215
|
|
|
@@ -394,18 +394,61 @@ For nested models the env var path is built as:
|
|
|
394
394
|
# e.g. MYAPP_DATABASE__HOST (prefix="MYAPP_", delimiter="__")
|
|
395
395
|
```
|
|
396
396
|
|
|
397
|
+
What pydantic-settings can set through env vars, and what the generated files show:
|
|
398
|
+
|
|
399
|
+
| Field | Env var example | Behaviour |
|
|
400
|
+
|---|---|---|
|
|
401
|
+
| Field of a nested model, any depth | `MYAPP_DATABASE__SERVER__HOST=db` | Sets only this value. The other values from the YAML file stay. |
|
|
402
|
+
| Whole nested model | `MYAPP_DATABASE='{"name": "x"}'` | JSON, merged into the values from the YAML file. |
|
|
403
|
+
| List (`List[str]`, `List[SubModel]`) | `MYAPP_HOSTS='["a", "b"]'` | JSON, replaces the whole list. A single list element (`MYAPP_HOSTS__0`) can not be set, so list elements show no env var. |
|
|
404
|
+
| Dict value (`Dict[str, ...]`) | `MYAPP_LABELS__team=core` | `<dict-key>` stands for the key. Env var names are case-insensitive by default, so the key arrives in lower case. |
|
|
405
|
+
|
|
406
|
+
Only the root model's `env_prefix` and `env_nested_delimiter` count. A nested `BaseSettings`
|
|
407
|
+
sub-model's own `env_prefix` is not used for its fields. Without `env_nested_delimiter`, nested
|
|
408
|
+
fields have no env var of their own and can only be set through the JSON value of the top-level
|
|
409
|
+
field. With `case_sensitive=True`, the env var names keep the case of the field names.
|
|
410
|
+
A sub-model that defaults to null has no fields in the YAML file, so its comment lists the env
|
|
411
|
+
vars of its fields under `Env-vars of the sub-fields:`.
|
|
412
|
+
|
|
413
|
+
#### Nullable fields
|
|
414
|
+
|
|
415
|
+
A nullable field (`Optional[int]`, `int | None`) shows its type as `int or null`. By default,
|
|
416
|
+
pydantic-settings can not set such a field to null through an env var: an `int` or `bool` field
|
|
417
|
+
fails validation, a `str` field gets the literal text `null`, and a list, dict or model field
|
|
418
|
+
silently keeps its default. Only the YAML file can set null then. Set `env_parse_none_str` to name
|
|
419
|
+
the env var value that means null:
|
|
420
|
+
|
|
421
|
+
```python
|
|
422
|
+
class MyConfig(BaseSettings):
|
|
423
|
+
model_config = SettingsConfigDict(env_prefix="MYAPP_", env_parse_none_str="null")
|
|
424
|
+
|
|
425
|
+
max_age_days: Optional[int] = 30
|
|
426
|
+
# → Type: int or null
|
|
427
|
+
# → Env-var: 'MYAPP_MAX_AGE_DAYS' ('null' sets null)
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
For a nullable field whose default is not null, the `Env-var:` hint says which case applies:
|
|
431
|
+
`('null' sets null)` with `env_parse_none_str`, or `(can not set null, use null in the YAML file)`
|
|
432
|
+
without it. A field that defaults to null gets no hint, since leaving the env var unset already
|
|
433
|
+
gives null.
|
|
434
|
+
|
|
397
435
|
---
|
|
398
436
|
|
|
399
437
|
## Environment variable overrides
|
|
400
438
|
|
|
401
439
|
`load()` creates a thin dynamic subclass that adds the YAML file as a pydantic-settings source while keeping the normal env var source with higher priority. The effective source priority (highest → lowest) is:
|
|
402
440
|
|
|
403
|
-
1. **
|
|
404
|
-
2. **
|
|
405
|
-
3. **
|
|
441
|
+
1. **Init arguments** (values passed to the settings class directly)
|
|
442
|
+
2. **Environment variables** (respects `env_prefix` / `env_nested_delimiter`)
|
|
443
|
+
3. **`.env` file** (if the model sets `env_file`)
|
|
444
|
+
4. **Secrets directory** (if the model sets `secrets_dir`)
|
|
445
|
+
5. **YAML file** values
|
|
446
|
+
6. **Model defaults**
|
|
406
447
|
|
|
407
448
|
No changes to your settings class are required.
|
|
408
449
|
|
|
450
|
+
A missing YAML file is not an error: `load()` then returns the model defaults plus whatever the env vars, the `.env` file and the secrets directory set.
|
|
451
|
+
|
|
409
452
|
---
|
|
410
453
|
|
|
411
454
|
## Development
|
|
@@ -434,7 +477,7 @@ To visually inspect the generated YAML and Markdown for all built-in test models
|
|
|
434
477
|
pdm run smoke
|
|
435
478
|
```
|
|
436
479
|
|
|
437
|
-
This writes
|
|
480
|
+
This writes two files per model (YAML config template + Markdown reference) to `smoke_output/` and prints a preview of each to the terminal. The output is committed to the repository and linked from the [Example gallery](#example-gallery) section. Re-run the script and commit the results whenever the generator logic changes.
|
|
438
481
|
|
|
439
482
|
```
|
|
440
483
|
smoke_output/
|
|
@@ -125,18 +125,18 @@ The command above creates `config.yaml` with a comment block above every key:
|
|
|
125
125
|
|
|
126
126
|
```yaml
|
|
127
127
|
# ## log_level ###
|
|
128
|
-
# Type: Enum
|
|
128
|
+
# Type: Enum or null
|
|
129
129
|
# Required: False
|
|
130
130
|
# Default: "INFO"
|
|
131
131
|
# Allowed vals: ['INFO', 'DEBUG']
|
|
132
|
-
# Env-var: 'APP_LOG_LEVEL'
|
|
132
|
+
# Env-var: 'APP_LOG_LEVEL' (can not set null, use null in the YAML file)
|
|
133
133
|
log_level: INFO
|
|
134
134
|
|
|
135
135
|
# ## app_name ###
|
|
136
|
-
# Type: str
|
|
136
|
+
# Type: str or null
|
|
137
137
|
# Required: False
|
|
138
138
|
# Default: "THE APP"
|
|
139
|
-
# Env-var: 'APP_APP_NAME'
|
|
139
|
+
# Env-var: 'APP_APP_NAME' (can not set null, use null in the YAML file)
|
|
140
140
|
# Description: The display name of the app
|
|
141
141
|
# Example No. 1:
|
|
142
142
|
# >app_name: THAT APP
|
|
@@ -145,9 +145,9 @@ log_level: INFO
|
|
|
145
145
|
app_name: THE APP
|
|
146
146
|
|
|
147
147
|
# ## storage_dir ###
|
|
148
|
-
# Type: str
|
|
148
|
+
# Type: str or null
|
|
149
149
|
# Required: False
|
|
150
|
-
# Env-var: 'APP_STORAGE_DIR'
|
|
150
|
+
# Env-var: 'APP_STORAGE_DIR' (can not set null, use null in the YAML file)
|
|
151
151
|
# Description: A directory to store files for the app.
|
|
152
152
|
storage_dir: /home/user/.config/myapp
|
|
153
153
|
|
|
@@ -174,19 +174,19 @@ database_server:
|
|
|
174
174
|
|
|
175
175
|
# ## host ###
|
|
176
176
|
# YAML-path: database_server.host
|
|
177
|
-
# Type: str
|
|
177
|
+
# Type: str or null
|
|
178
178
|
# Required: False
|
|
179
179
|
# Default: "localhost"
|
|
180
|
-
# Env-var: 'APP_DATABASE_SERVER__HOST'
|
|
180
|
+
# Env-var: 'APP_DATABASE_SERVER__HOST' (can not set null, use null in the YAML file)
|
|
181
181
|
# Description: The hostname the database will be available at
|
|
182
182
|
host: localhost
|
|
183
183
|
|
|
184
184
|
# ## port ###
|
|
185
185
|
# YAML-path: database_server.port
|
|
186
|
-
# Type: int
|
|
186
|
+
# Type: int or null
|
|
187
187
|
# Required: False
|
|
188
188
|
# Default: 5678
|
|
189
|
-
# Env-var: 'APP_DATABASE_SERVER__PORT'
|
|
189
|
+
# Env-var: 'APP_DATABASE_SERVER__PORT' (can not set null, use null in the YAML file)
|
|
190
190
|
# Description: The port to connect to the database
|
|
191
191
|
port: 5678
|
|
192
192
|
|
|
@@ -371,18 +371,61 @@ For nested models the env var path is built as:
|
|
|
371
371
|
# e.g. MYAPP_DATABASE__HOST (prefix="MYAPP_", delimiter="__")
|
|
372
372
|
```
|
|
373
373
|
|
|
374
|
+
What pydantic-settings can set through env vars, and what the generated files show:
|
|
375
|
+
|
|
376
|
+
| Field | Env var example | Behaviour |
|
|
377
|
+
|---|---|---|
|
|
378
|
+
| Field of a nested model, any depth | `MYAPP_DATABASE__SERVER__HOST=db` | Sets only this value. The other values from the YAML file stay. |
|
|
379
|
+
| Whole nested model | `MYAPP_DATABASE='{"name": "x"}'` | JSON, merged into the values from the YAML file. |
|
|
380
|
+
| List (`List[str]`, `List[SubModel]`) | `MYAPP_HOSTS='["a", "b"]'` | JSON, replaces the whole list. A single list element (`MYAPP_HOSTS__0`) can not be set, so list elements show no env var. |
|
|
381
|
+
| Dict value (`Dict[str, ...]`) | `MYAPP_LABELS__team=core` | `<dict-key>` stands for the key. Env var names are case-insensitive by default, so the key arrives in lower case. |
|
|
382
|
+
|
|
383
|
+
Only the root model's `env_prefix` and `env_nested_delimiter` count. A nested `BaseSettings`
|
|
384
|
+
sub-model's own `env_prefix` is not used for its fields. Without `env_nested_delimiter`, nested
|
|
385
|
+
fields have no env var of their own and can only be set through the JSON value of the top-level
|
|
386
|
+
field. With `case_sensitive=True`, the env var names keep the case of the field names.
|
|
387
|
+
A sub-model that defaults to null has no fields in the YAML file, so its comment lists the env
|
|
388
|
+
vars of its fields under `Env-vars of the sub-fields:`.
|
|
389
|
+
|
|
390
|
+
#### Nullable fields
|
|
391
|
+
|
|
392
|
+
A nullable field (`Optional[int]`, `int | None`) shows its type as `int or null`. By default,
|
|
393
|
+
pydantic-settings can not set such a field to null through an env var: an `int` or `bool` field
|
|
394
|
+
fails validation, a `str` field gets the literal text `null`, and a list, dict or model field
|
|
395
|
+
silently keeps its default. Only the YAML file can set null then. Set `env_parse_none_str` to name
|
|
396
|
+
the env var value that means null:
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
class MyConfig(BaseSettings):
|
|
400
|
+
model_config = SettingsConfigDict(env_prefix="MYAPP_", env_parse_none_str="null")
|
|
401
|
+
|
|
402
|
+
max_age_days: Optional[int] = 30
|
|
403
|
+
# → Type: int or null
|
|
404
|
+
# → Env-var: 'MYAPP_MAX_AGE_DAYS' ('null' sets null)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
For a nullable field whose default is not null, the `Env-var:` hint says which case applies:
|
|
408
|
+
`('null' sets null)` with `env_parse_none_str`, or `(can not set null, use null in the YAML file)`
|
|
409
|
+
without it. A field that defaults to null gets no hint, since leaving the env var unset already
|
|
410
|
+
gives null.
|
|
411
|
+
|
|
374
412
|
---
|
|
375
413
|
|
|
376
414
|
## Environment variable overrides
|
|
377
415
|
|
|
378
416
|
`load()` creates a thin dynamic subclass that adds the YAML file as a pydantic-settings source while keeping the normal env var source with higher priority. The effective source priority (highest → lowest) is:
|
|
379
417
|
|
|
380
|
-
1. **
|
|
381
|
-
2. **
|
|
382
|
-
3. **
|
|
418
|
+
1. **Init arguments** (values passed to the settings class directly)
|
|
419
|
+
2. **Environment variables** (respects `env_prefix` / `env_nested_delimiter`)
|
|
420
|
+
3. **`.env` file** (if the model sets `env_file`)
|
|
421
|
+
4. **Secrets directory** (if the model sets `secrets_dir`)
|
|
422
|
+
5. **YAML file** values
|
|
423
|
+
6. **Model defaults**
|
|
383
424
|
|
|
384
425
|
No changes to your settings class are required.
|
|
385
426
|
|
|
427
|
+
A missing YAML file is not an error: `load()` then returns the model defaults plus whatever the env vars, the `.env` file and the secrets directory set.
|
|
428
|
+
|
|
386
429
|
---
|
|
387
430
|
|
|
388
431
|
## Development
|
|
@@ -411,7 +454,7 @@ To visually inspect the generated YAML and Markdown for all built-in test models
|
|
|
411
454
|
pdm run smoke
|
|
412
455
|
```
|
|
413
456
|
|
|
414
|
-
This writes
|
|
457
|
+
This writes two files per model (YAML config template + Markdown reference) to `smoke_output/` and prints a preview of each to the terminal. The output is committed to the repository and linked from the [Example gallery](#example-gallery) section. Re-run the script and commit the results whenever the generator logic changes.
|
|
415
458
|
|
|
416
459
|
```
|
|
417
460
|
smoke_output/
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
from pydantic import fields as pydantic_fields
|
|
10
|
+
from pydantic_core import PydanticUndefined
|
|
11
|
+
from pydantic_settings import BaseSettings
|
|
12
|
+
|
|
13
|
+
from psyplus.utils import (
|
|
14
|
+
clean_annotation,
|
|
15
|
+
get_literal_list,
|
|
16
|
+
get_str_dict_as_table,
|
|
17
|
+
has_literal,
|
|
18
|
+
is_nullable,
|
|
19
|
+
python_annotation_to_readable,
|
|
20
|
+
readable_constraints,
|
|
21
|
+
to_json_text,
|
|
22
|
+
to_yaml_text,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
ENV_VAR_LISTINDEX_PLACEHOLDER = "<list-index>"
|
|
28
|
+
ENV_VAR_DICTKEY_PLACEHOLDER = "<dict-key>"
|
|
29
|
+
# Stands for "any element" in a path, e.g. ``servers[*].host`` in the Markdown doc.
|
|
30
|
+
ANY_ITEM = "*"
|
|
31
|
+
|
|
32
|
+
_warned_no_delimiter: set[type] = set()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ListIndex:
|
|
37
|
+
index: int | str
|
|
38
|
+
|
|
39
|
+
def __str__(self) -> str:
|
|
40
|
+
return f"[{self.index}]"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class DictKey:
|
|
45
|
+
key: Any
|
|
46
|
+
|
|
47
|
+
def __str__(self) -> str:
|
|
48
|
+
if self.key == ANY_ITEM:
|
|
49
|
+
return "[*]"
|
|
50
|
+
return f"['{self.key}']"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def path_to_str(path: list[str | ListIndex | DictKey]) -> str:
|
|
54
|
+
"""``["servers", ListIndex(0), "host"]`` -> ``servers[0].host``."""
|
|
55
|
+
out = ""
|
|
56
|
+
for segment in path:
|
|
57
|
+
if isinstance(segment, str):
|
|
58
|
+
out += f".{segment}" if out else segment
|
|
59
|
+
else:
|
|
60
|
+
out += str(segment)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class FieldInfoContainer:
|
|
66
|
+
"""Wraps metadata for a single settings field and generates YAML comments / markdown."""
|
|
67
|
+
|
|
68
|
+
path: list[str | ListIndex | DictKey]
|
|
69
|
+
field_name: str
|
|
70
|
+
root_settings_class: type[BaseSettings]
|
|
71
|
+
field_info: pydantic_fields.FieldInfo | None = None
|
|
72
|
+
# annotation is used for dict/list items where field_info is not available
|
|
73
|
+
annotation: Any = None
|
|
74
|
+
|
|
75
|
+
def get_annotation(self) -> Any:
|
|
76
|
+
if self.field_info is not None and self.field_info.annotation is not None:
|
|
77
|
+
return self.field_info.annotation
|
|
78
|
+
return self.annotation
|
|
79
|
+
|
|
80
|
+
def get_env_var(self) -> str | None:
|
|
81
|
+
"""The env var that sets this field, or None when pydantic-settings offers none.
|
|
82
|
+
|
|
83
|
+
pydantic-settings can not set a single list element: ``APP_ITEMS__0`` is read
|
|
84
|
+
as the dict ``{"0": ...}`` and fails list validation, so list elements only
|
|
85
|
+
change through the JSON value of the whole list. Without
|
|
86
|
+
``env_nested_delimiter`` only top-level fields have an env var.
|
|
87
|
+
"""
|
|
88
|
+
config = self.root_settings_class.model_config
|
|
89
|
+
delimiter = config.get("env_nested_delimiter") or ""
|
|
90
|
+
case_sensitive = bool(config.get("case_sensitive", False))
|
|
91
|
+
prefix = config.get("env_prefix") or ""
|
|
92
|
+
if not case_sensitive:
|
|
93
|
+
prefix = prefix.upper()
|
|
94
|
+
|
|
95
|
+
if any(isinstance(segment, ListIndex) for segment in self.path):
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
if len(self.path) > 1 and not delimiter:
|
|
99
|
+
if (
|
|
100
|
+
not os.getenv("PSYPLUS_SUPPRESS_ENV_WARNING")
|
|
101
|
+
and self.root_settings_class not in _warned_no_delimiter
|
|
102
|
+
):
|
|
103
|
+
_warned_no_delimiter.add(self.root_settings_class)
|
|
104
|
+
log.warning(
|
|
105
|
+
f"`{self.root_settings_class.__name__}` has nested fields but no "
|
|
106
|
+
"`env_nested_delimiter`, so they can only be set through the JSON value "
|
|
107
|
+
"of their top-level env var. "
|
|
108
|
+
"Set env var PSYPLUS_SUPPRESS_ENV_WARNING=true to silence this warning. "
|
|
109
|
+
"See https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values"
|
|
110
|
+
)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
parts: list[str] = []
|
|
114
|
+
for segment in self.path:
|
|
115
|
+
if isinstance(segment, str):
|
|
116
|
+
parts.append(segment if case_sensitive else segment.upper())
|
|
117
|
+
else:
|
|
118
|
+
parts.append(ENV_VAR_DICTKEY_PLACEHOLDER)
|
|
119
|
+
return prefix + delimiter.join(parts)
|
|
120
|
+
|
|
121
|
+
def get_path_str(self) -> str:
|
|
122
|
+
return path_to_str(self.path)
|
|
123
|
+
|
|
124
|
+
def get_type_string(self) -> str | None:
|
|
125
|
+
type_str = python_annotation_to_readable(self.get_annotation())
|
|
126
|
+
if type_str and is_nullable(self.get_annotation()):
|
|
127
|
+
type_str += " or null"
|
|
128
|
+
return type_str
|
|
129
|
+
|
|
130
|
+
def needs_env_var_null_note(self) -> bool:
|
|
131
|
+
"""True for a nullable field whose default is not null.
|
|
132
|
+
|
|
133
|
+
Only there does a user have a reason to set null through the env var, and
|
|
134
|
+
pydantic-settings can not do that unless the model sets
|
|
135
|
+
``env_parse_none_str``: without it an int or bool field fails validation,
|
|
136
|
+
a str field gets the literal text, and a list, dict or model field silently
|
|
137
|
+
keeps its default.
|
|
138
|
+
"""
|
|
139
|
+
if self.field_info is None or self.field_info.default is None:
|
|
140
|
+
return False
|
|
141
|
+
return is_nullable(self.get_annotation())
|
|
142
|
+
|
|
143
|
+
def get_env_parse_none_str(self) -> str | None:
|
|
144
|
+
"""The env var value that means null (``env_parse_none_str``), if the model sets one."""
|
|
145
|
+
return self.root_settings_class.model_config.get("env_parse_none_str")
|
|
146
|
+
|
|
147
|
+
def get_enum_vals(self) -> list[Any] | None:
|
|
148
|
+
if has_literal(self.get_annotation()):
|
|
149
|
+
return get_literal_list(self.get_annotation())
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def get_sub_field_env_vars(self) -> list[tuple[str, str]]:
|
|
153
|
+
"""``(path, env var)`` for the fields of this field's sub-model, recursively.
|
|
154
|
+
|
|
155
|
+
Only direct sub-models are followed (``Optional[Model]`` included); fields
|
|
156
|
+
without an env var of their own (list elements, no ``env_nested_delimiter``)
|
|
157
|
+
are left out.
|
|
158
|
+
"""
|
|
159
|
+
out: list[tuple[str, str]] = []
|
|
160
|
+
|
|
161
|
+
def walk(container: FieldInfoContainer, seen: tuple[type, ...]) -> None:
|
|
162
|
+
cls = clean_annotation(container.get_annotation())
|
|
163
|
+
if not (isinstance(cls, type) and issubclass(cls, BaseModel)) or cls in seen:
|
|
164
|
+
return
|
|
165
|
+
for key, fi in cls.model_fields.items():
|
|
166
|
+
child = FieldInfoContainer(
|
|
167
|
+
path=container.path + [key],
|
|
168
|
+
field_name=key,
|
|
169
|
+
field_info=fi,
|
|
170
|
+
root_settings_class=self.root_settings_class,
|
|
171
|
+
)
|
|
172
|
+
env_var = child.get_env_var()
|
|
173
|
+
if env_var:
|
|
174
|
+
out.append((child.get_path_str(), env_var))
|
|
175
|
+
walk(child, seen + (cls,))
|
|
176
|
+
|
|
177
|
+
walk(self, ())
|
|
178
|
+
return out
|
|
179
|
+
|
|
180
|
+
def build_comment_lines(
|
|
181
|
+
self,
|
|
182
|
+
overwrite_required: bool | None = None,
|
|
183
|
+
list_sub_field_env_vars: bool = False,
|
|
184
|
+
) -> list[str]:
|
|
185
|
+
"""Build the list of lines that form the YAML comment block above a key.
|
|
186
|
+
|
|
187
|
+
``list_sub_field_env_vars`` adds the env vars of the sub-model's fields. The
|
|
188
|
+
YAML generator sets it when the sub-model is null, because its fields (and
|
|
189
|
+
their comments) are then not written to the file.
|
|
190
|
+
"""
|
|
191
|
+
fi = self.field_info or pydantic_fields.FieldInfo()
|
|
192
|
+
key = self.field_name
|
|
193
|
+
path = self.get_path_str()
|
|
194
|
+
|
|
195
|
+
header = f"## {key}"
|
|
196
|
+
if fi.title:
|
|
197
|
+
header += f" - {fi.title}"
|
|
198
|
+
header += " ###"
|
|
199
|
+
|
|
200
|
+
data: dict[str, str] = {}
|
|
201
|
+
|
|
202
|
+
if key != path:
|
|
203
|
+
data["YAML-path:"] = path
|
|
204
|
+
|
|
205
|
+
type_str = self.get_type_string()
|
|
206
|
+
if type_str:
|
|
207
|
+
data["Type:"] = type_str
|
|
208
|
+
|
|
209
|
+
is_required = fi.is_required() if overwrite_required is None else overwrite_required
|
|
210
|
+
data["Required:"] = str(is_required)
|
|
211
|
+
|
|
212
|
+
if fi.default is not PydanticUndefined:
|
|
213
|
+
if fi.default is None:
|
|
214
|
+
data["Default:"] = "null"
|
|
215
|
+
else:
|
|
216
|
+
data["Default:"] = to_json_text(fi.default)
|
|
217
|
+
|
|
218
|
+
enum_vals = self.get_enum_vals()
|
|
219
|
+
if enum_vals is not None:
|
|
220
|
+
data["Allowed vals:"] = str(enum_vals)
|
|
221
|
+
|
|
222
|
+
constraints = readable_constraints(fi.metadata)
|
|
223
|
+
if constraints:
|
|
224
|
+
data["Constraints:"] = constraints
|
|
225
|
+
|
|
226
|
+
env_var = self.get_env_var()
|
|
227
|
+
if env_var:
|
|
228
|
+
data["Env-var:"] = f"'{env_var}'"
|
|
229
|
+
if self.needs_env_var_null_note():
|
|
230
|
+
none_str = self.get_env_parse_none_str()
|
|
231
|
+
if none_str is not None:
|
|
232
|
+
data["Env-var:"] += f" ('{none_str}' sets null)"
|
|
233
|
+
else:
|
|
234
|
+
data["Env-var:"] += " (can not set null, use null in the YAML file)"
|
|
235
|
+
|
|
236
|
+
if fi.description:
|
|
237
|
+
data["Description:"] = fi.description
|
|
238
|
+
|
|
239
|
+
table = get_str_dict_as_table(data)
|
|
240
|
+
lines = [header]
|
|
241
|
+
if table:
|
|
242
|
+
lines.extend(table.split("\n"))
|
|
243
|
+
|
|
244
|
+
if list_sub_field_env_vars:
|
|
245
|
+
sub_env_vars = self.get_sub_field_env_vars()
|
|
246
|
+
if sub_env_vars:
|
|
247
|
+
lines.append("Env-vars of the sub-fields:")
|
|
248
|
+
lines.extend(f" {sub_path}: '{env}'" for sub_path, env in sub_env_vars)
|
|
249
|
+
|
|
250
|
+
if fi.examples:
|
|
251
|
+
lines.extend(self._example_lines(key))
|
|
252
|
+
|
|
253
|
+
return lines
|
|
254
|
+
|
|
255
|
+
def _example_lines(self, key: str) -> list[str]:
|
|
256
|
+
if not self.field_info or not self.field_info.examples:
|
|
257
|
+
return []
|
|
258
|
+
examples = self.field_info.examples
|
|
259
|
+
lines: list[str] = []
|
|
260
|
+
for i, example in enumerate(examples):
|
|
261
|
+
label = f"Example No. {i + 1}:" if len(examples) > 1 else "Example:"
|
|
262
|
+
lines.append(label)
|
|
263
|
+
try:
|
|
264
|
+
as_yaml = to_yaml_text({key: example})
|
|
265
|
+
for line in as_yaml.rstrip("\n").split("\n"):
|
|
266
|
+
lines.append(f" >{line}")
|
|
267
|
+
except Exception:
|
|
268
|
+
lines.append(f" >{repr(example)}")
|
|
269
|
+
return lines
|
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import logging
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
from typing import Literal, Type, Union
|
|
6
5
|
|
|
7
|
-
import
|
|
8
|
-
from pydantic_settings import BaseSettings
|
|
6
|
+
from pydantic_settings import BaseSettings, YamlConfigSettingsSource
|
|
9
7
|
|
|
10
8
|
from psyplus.markdown_generator import MarkdownDocGenerator
|
|
11
9
|
from psyplus.yaml_generator import YamlFileGenerator
|
|
12
10
|
|
|
13
|
-
log = logging.getLogger(__name__)
|
|
14
|
-
|
|
15
11
|
|
|
16
12
|
class YamlSettingsPlus:
|
|
17
13
|
"""Central helper for generating and reading pydantic-settings YAML configs.
|
|
@@ -47,6 +43,10 @@ class YamlSettingsPlus:
|
|
|
47
43
|
"""
|
|
48
44
|
if self.config_file is None:
|
|
49
45
|
raise ValueError("file_path must be set to generate a config file.")
|
|
46
|
+
if on_exists not in ("skip", "overwrite", "error"):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"on_exists must be 'skip', 'overwrite' or 'error', not {on_exists!r}."
|
|
49
|
+
)
|
|
50
50
|
|
|
51
51
|
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
52
52
|
|
|
@@ -60,11 +60,11 @@ class YamlSettingsPlus:
|
|
|
60
60
|
)
|
|
61
61
|
# on_exists == "overwrite" → fall through
|
|
62
62
|
|
|
63
|
-
self.config_file.write_text(self.render_yaml())
|
|
63
|
+
self.config_file.write_text(self.render_yaml(), encoding="utf-8")
|
|
64
64
|
|
|
65
65
|
def generate_markdown_doc(self, output_path: Union[str, Path]) -> None:
|
|
66
66
|
"""Write a Markdown reference document for the settings model to *output_path*."""
|
|
67
|
-
Path(output_path).write_text(self.render_markdown())
|
|
67
|
+
Path(output_path).write_text(self.render_markdown(), encoding="utf-8")
|
|
68
68
|
|
|
69
69
|
# ------------------------------------------------------------------
|
|
70
70
|
# Render to string (no file I/O)
|
|
@@ -91,8 +91,10 @@ class YamlSettingsPlus:
|
|
|
91
91
|
``env_nested_delimiter``) take priority over YAML file values,
|
|
92
92
|
following standard pydantic-settings source precedence.
|
|
93
93
|
|
|
94
|
-
Tip:
|
|
95
|
-
|
|
94
|
+
Tip: to get similar behaviour without psyplus, set ``yaml_file`` in
|
|
95
|
+
your model's ``model_config`` and add a ``YamlConfigSettingsSource``
|
|
96
|
+
in ``settings_customise_sources``. Setting ``yaml_file`` alone is not
|
|
97
|
+
enough, pydantic-settings does not read it by default::
|
|
96
98
|
|
|
97
99
|
class MyConfig(BaseSettings):
|
|
98
100
|
model_config = SettingsConfigDict(
|
|
@@ -101,43 +103,46 @@ class YamlSettingsPlus:
|
|
|
101
103
|
env_nested_delimiter="__",
|
|
102
104
|
)
|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
@classmethod
|
|
107
|
+
def settings_customise_sources(
|
|
108
|
+
cls, settings_cls, init_settings, env_settings,
|
|
109
|
+
dotenv_settings, file_secret_settings,
|
|
110
|
+
):
|
|
111
|
+
return (
|
|
112
|
+
init_settings, env_settings, dotenv_settings,
|
|
113
|
+
file_secret_settings,
|
|
114
|
+
YamlConfigSettingsSource(settings_cls),
|
|
115
|
+
)
|
|
105
116
|
"""
|
|
106
117
|
if self.config_file is None:
|
|
107
118
|
raise ValueError("file_path must be set to load a config file.")
|
|
108
119
|
|
|
109
120
|
yaml_path = self.config_file
|
|
110
121
|
base_cls = self.model
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
# The generated file is written as UTF-8, so read it as UTF-8 unless the
|
|
123
|
+
# model asks for another encoding.
|
|
124
|
+
encoding = base_cls.model_config.get("yaml_file_encoding") or "utf-8"
|
|
125
|
+
|
|
126
|
+
class _ModelWithYaml(base_cls): # type: ignore[valid-type,misc]
|
|
127
|
+
@classmethod
|
|
128
|
+
def settings_customise_sources(
|
|
129
|
+
cls,
|
|
130
|
+
settings_cls: Type[BaseSettings],
|
|
131
|
+
init_settings,
|
|
132
|
+
env_settings,
|
|
133
|
+
dotenv_settings,
|
|
134
|
+
file_secret_settings,
|
|
135
|
+
):
|
|
136
|
+
# The YAML file is the lowest file based source: env vars, the
|
|
137
|
+
# .env file and the secrets directory all override it.
|
|
138
|
+
return (
|
|
123
139
|
init_settings,
|
|
124
140
|
env_settings,
|
|
125
141
|
dotenv_settings,
|
|
126
142
|
file_secret_settings,
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
_YamlSource(settings_cls, yaml_file=yaml_path),
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
return _ModelWithYaml()
|
|
143
|
+
YamlConfigSettingsSource(
|
|
144
|
+
settings_cls, yaml_file=yaml_path, yaml_file_encoding=encoding
|
|
145
|
+
),
|
|
146
|
+
)
|
|
135
147
|
|
|
136
|
-
|
|
137
|
-
log.warning(
|
|
138
|
-
"pydantic-settings >= 2.3 is required for automatic env-var overlay "
|
|
139
|
-
"in load(). Falling back to YAML-only loading."
|
|
140
|
-
)
|
|
141
|
-
with open(yaml_path) as fh:
|
|
142
|
-
data = yaml.safe_load(fh) or {}
|
|
143
|
-
return self.model.model_validate(data)
|
|
148
|
+
return _ModelWithYaml()
|