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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: psyplus
3
- Version: 1.0.0
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. **Environment variables** (respects `env_prefix` / `env_nested_delimiter`)
404
- 2. **YAML file** values
405
- 3. **Model defaults**
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 four 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.
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. **Environment variables** (respects `env_prefix` / `env_nested_delimiter`)
381
- 2. **YAML file** values
382
- 3. **Model defaults**
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 four 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.
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 yaml
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: if you want this behaviour without psyplus, configure
95
- ``yaml_file`` directly in your model's ``model_config``::
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
- config = MyConfig() # reads YAML + env vars automatically
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
- try:
113
- try:
114
- from pydantic_settings import YamlSettingsSource as _YamlSource
115
- except ImportError:
116
- from pydantic_settings import YamlConfigSettingsSource as _YamlSource # type: ignore[assignment]
117
-
118
- class _ModelWithYaml(base_cls): # type: ignore[valid-type,misc]
119
- @classmethod
120
- def settings_customise_sources(
121
- cls,
122
- settings_cls: Type[BaseSettings],
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
- return (
129
- init_settings,
130
- env_settings,
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
- except ImportError:
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()