msgspec-conf 1.3.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.
- msgspec_conf-1.3.0/.gitignore +64 -0
- msgspec_conf-1.3.0/LICENSE +21 -0
- msgspec_conf-1.3.0/PKG-INFO +233 -0
- msgspec_conf-1.3.0/README.md +212 -0
- msgspec_conf-1.3.0/pyproject.toml +50 -0
- msgspec_conf-1.3.0/src/msgspec_conf/__init__.py +15 -0
- msgspec_conf-1.3.0/src/msgspec_conf/core.py +748 -0
- msgspec_conf-1.3.0/src/msgspec_conf/py.typed +1 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
.Python
|
|
7
|
+
env/
|
|
8
|
+
build/
|
|
9
|
+
develop-eggs/
|
|
10
|
+
dist/
|
|
11
|
+
downloads/
|
|
12
|
+
eggs/
|
|
13
|
+
.eggs/
|
|
14
|
+
lib/
|
|
15
|
+
lib64/
|
|
16
|
+
parts/
|
|
17
|
+
sdist/
|
|
18
|
+
var/
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.installed.cfg
|
|
21
|
+
*.egg
|
|
22
|
+
poetry.lock
|
|
23
|
+
|
|
24
|
+
# Virtual Environment
|
|
25
|
+
venv/
|
|
26
|
+
ENV/
|
|
27
|
+
.env
|
|
28
|
+
|
|
29
|
+
# IDE specific files
|
|
30
|
+
.idea/
|
|
31
|
+
.vscode/
|
|
32
|
+
*.swp
|
|
33
|
+
*.swo
|
|
34
|
+
|
|
35
|
+
# Logs
|
|
36
|
+
*.log
|
|
37
|
+
logs/
|
|
38
|
+
|
|
39
|
+
# Testing
|
|
40
|
+
.pytest_cache/
|
|
41
|
+
.coverage
|
|
42
|
+
htmlcov/
|
|
43
|
+
|
|
44
|
+
# Database
|
|
45
|
+
*.sqlite3
|
|
46
|
+
*.db
|
|
47
|
+
|
|
48
|
+
# Alembic
|
|
49
|
+
# Keep alembic versions in git
|
|
50
|
+
|
|
51
|
+
# Miscellaneous
|
|
52
|
+
.DS_Store
|
|
53
|
+
node_modules/
|
|
54
|
+
|
|
55
|
+
# Secrets and local configs
|
|
56
|
+
.secrets
|
|
57
|
+
*.local.*
|
|
58
|
+
.env.local
|
|
59
|
+
.ruff_cache
|
|
60
|
+
|
|
61
|
+
# AI
|
|
62
|
+
.qodo
|
|
63
|
+
.claude
|
|
64
|
+
CLAUDE.local.md
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 THEROER
|
|
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,233 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msgspec-conf
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: msgspec-based settings loader with env var, .env, YAML and TOML support
|
|
5
|
+
Project-URL: Homepage, https://github.com/THEROER/env-settings
|
|
6
|
+
Project-URL: Repository, https://github.com/THEROER/env-settings
|
|
7
|
+
Project-URL: Issues, https://github.com/THEROER/env-settings/issues
|
|
8
|
+
Author-email: THEROER <theroer09@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: config,configuration,dotenv,msgspec,settings,toml,yaml
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: <4,>=3.11
|
|
18
|
+
Requires-Dist: msgspec<0.22,>=0.21.0
|
|
19
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# msgspec-conf
|
|
23
|
+
|
|
24
|
+
A tiny settings loader inspired by `pydantic_settings`, but implemented with [`msgspec`](https://github.com/jcrist/msgspec). It allows loading structured configuration from environment variables, `.env` files, YAML and TOML config files without depending on Pydantic.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Define settings as `msgspec.Struct` classes
|
|
29
|
+
- Load values from the current environment, optional `.env` files, YAML or TOML config files, and defaults
|
|
30
|
+
- Automatic type coercion for scalar values and common collection formats
|
|
31
|
+
- Optional prefixes and case-insensitive matching
|
|
32
|
+
- File-backed secret fallback via `*_FILE` variables
|
|
33
|
+
- Declarative loading for top-level settings composed from prefixed blocks
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from typing import Optional
|
|
39
|
+
import msgspec
|
|
40
|
+
|
|
41
|
+
from msgspec_conf import BaseSettings
|
|
42
|
+
|
|
43
|
+
class AppSettings(BaseSettings):
|
|
44
|
+
debug: bool = False
|
|
45
|
+
database_url: str
|
|
46
|
+
api_key: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
settings = AppSettings.load(env_file=".env", prefix="APP_")
|
|
49
|
+
print(settings.database_url)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`.env` values override defaults, while real environment variables take precedence over the file.
|
|
53
|
+
|
|
54
|
+
## YAML and TOML config files
|
|
55
|
+
|
|
56
|
+
A config file can supply structured, non-secret configuration — the kind you
|
|
57
|
+
commit to git — while `.env` keeps private values out of the repository. The
|
|
58
|
+
two sources are independent: each has its own path and either can be used alone
|
|
59
|
+
or together. The file is parsed as TOML when it has a `.toml` suffix and as YAML
|
|
60
|
+
otherwise.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
settings = AppSettings.load(config_file="config.yaml", env_file=".env")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```yaml
|
|
67
|
+
# config.yaml — safe to commit
|
|
68
|
+
debug: false
|
|
69
|
+
host: api.example.com
|
|
70
|
+
tags:
|
|
71
|
+
- landing
|
|
72
|
+
- monitoring
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Values resolve with the precedence **real env vars > `.env` file > config file >
|
|
76
|
+
defaults**. So if `HOST` appears both in `config.yaml` and the environment, the
|
|
77
|
+
environment wins; fields only present in the config file are still applied.
|
|
78
|
+
|
|
79
|
+
The same applies to TOML. To read settings straight out of `pyproject.toml`,
|
|
80
|
+
point `config_file` at it and use `config_table` to select the table (a dotted
|
|
81
|
+
path) that holds your settings:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
settings = AppSettings.load(
|
|
85
|
+
config_file="pyproject.toml",
|
|
86
|
+
config_table="tool.myservice",
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```toml
|
|
91
|
+
# pyproject.toml
|
|
92
|
+
[tool.myservice]
|
|
93
|
+
debug = false
|
|
94
|
+
host = "api.example.com"
|
|
95
|
+
tags = ["landing", "monitoring"]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`config_table` works with any config file format; without it the whole document
|
|
99
|
+
is used. `load_composed_settings` accepts both `config_file` and `config_table`
|
|
100
|
+
as well, so each prefixed block can be configured from a nested table.
|
|
101
|
+
|
|
102
|
+
Because YAML is already structured, native types are used directly — no string
|
|
103
|
+
parsing is needed for lists, mappings, or nested records:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
limits:
|
|
107
|
+
checkout: 20
|
|
108
|
+
login: 60
|
|
109
|
+
rules:
|
|
110
|
+
- { id: 1, score: 95 }
|
|
111
|
+
- { id: 2, score: 90 }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Top-level YAML keys are matched against field names case-insensitively.
|
|
115
|
+
|
|
116
|
+
List values can be written as CSV, semicolon/newline-delimited text, or JSON:
|
|
117
|
+
|
|
118
|
+
```env
|
|
119
|
+
APP_ALLOWED_ORIGINS=https://example.com,https://api.example.com
|
|
120
|
+
APP_ALLOWED_LOCALES=en;uk;fr
|
|
121
|
+
APP_ALLOWED_TOPICS=["landing.live_stats","monitoring.live_status"]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Dict values can be written as JSON or delimited key-value pairs:
|
|
125
|
+
|
|
126
|
+
```env
|
|
127
|
+
APP_LIMITS={"checkout":20,"login":60}
|
|
128
|
+
APP_LIMITS=checkout=20,login:60;refresh=120
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`list[dict[...]]` values can be written as JSON or as records separated by
|
|
132
|
+
semicolon/newline. Within each record, comma separates key-value pairs:
|
|
133
|
+
|
|
134
|
+
```env
|
|
135
|
+
APP_RULES=[{"id":"policy.example","score":95,"evidence_urls":["https://example.com"]}]
|
|
136
|
+
APP_RULES=id=1,score=95;id=2,score=90
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Prefer JSON when values are deeply nested or may contain delimiters.
|
|
140
|
+
|
|
141
|
+
Boolean values are parsed strictly. Accepted values are `1/0`, `true/false`,
|
|
142
|
+
`yes/no`, `on/off`, and `y/n`.
|
|
143
|
+
|
|
144
|
+
## File-backed values
|
|
145
|
+
|
|
146
|
+
If `TOKEN` is empty or unset, `TOKEN_FILE` can point to a file containing the
|
|
147
|
+
value:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
class SecretSettings(BaseSettings):
|
|
151
|
+
token: str = ""
|
|
152
|
+
token_file: str | None = None
|
|
153
|
+
|
|
154
|
+
settings = SecretSettings.load()
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```env
|
|
158
|
+
TOKEN_FILE=/run/secrets/api_token
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The `token_file` field is optional. `TOKEN_FILE` also works when only `token`
|
|
162
|
+
is defined.
|
|
163
|
+
|
|
164
|
+
## Composed settings
|
|
165
|
+
|
|
166
|
+
Services with a top-level `msgspec.Struct` can load nested settings blocks
|
|
167
|
+
declaratively:
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from msgspec_conf import BaseSettings, ServiceDefaultsBase, load_composed_settings
|
|
171
|
+
import msgspec
|
|
172
|
+
|
|
173
|
+
class DatabaseSettings(BaseSettings):
|
|
174
|
+
host: str = "localhost"
|
|
175
|
+
port: int = 5432
|
|
176
|
+
|
|
177
|
+
class ServiceDefaults(ServiceDefaultsBase):
|
|
178
|
+
service_name: str = "example-service"
|
|
179
|
+
|
|
180
|
+
class Settings(msgspec.Struct, kw_only=True):
|
|
181
|
+
debug: bool = False
|
|
182
|
+
service_name: str = "example-service"
|
|
183
|
+
database: DatabaseSettings = msgspec.field(default_factory=DatabaseSettings)
|
|
184
|
+
|
|
185
|
+
settings = load_composed_settings(
|
|
186
|
+
Settings,
|
|
187
|
+
env_file=".env",
|
|
188
|
+
defaults_cls=ServiceDefaults,
|
|
189
|
+
prefixes={"database": "POSTGRES_"},
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Default factories on nested fields are preserved and then overridden by matching
|
|
194
|
+
environment values.
|
|
195
|
+
|
|
196
|
+
A YAML config file works here too. Top-level keys map to shared fields, while
|
|
197
|
+
each nested mapping (keyed by the block's field name) configures that block:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
settings = load_composed_settings(
|
|
201
|
+
Settings,
|
|
202
|
+
config_file="config.yaml",
|
|
203
|
+
env_file=".env",
|
|
204
|
+
defaults_cls=ServiceDefaults,
|
|
205
|
+
prefixes={"database": "POSTGRES_"},
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
```yaml
|
|
210
|
+
debug: false
|
|
211
|
+
service_name: example-service
|
|
212
|
+
database:
|
|
213
|
+
host: db.internal
|
|
214
|
+
port: 5432
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Environment variables (e.g. `POSTGRES_HOST`) still override the matching YAML
|
|
218
|
+
values.
|
|
219
|
+
|
|
220
|
+
## Installation
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
pip install msgspec-conf
|
|
224
|
+
# or
|
|
225
|
+
uv add msgspec-conf
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Development
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
uv sync --dev
|
|
232
|
+
uv run pytest
|
|
233
|
+
```
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# msgspec-conf
|
|
2
|
+
|
|
3
|
+
A tiny settings loader inspired by `pydantic_settings`, but implemented with [`msgspec`](https://github.com/jcrist/msgspec). It allows loading structured configuration from environment variables, `.env` files, YAML and TOML config files without depending on Pydantic.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Define settings as `msgspec.Struct` classes
|
|
8
|
+
- Load values from the current environment, optional `.env` files, YAML or TOML config files, and defaults
|
|
9
|
+
- Automatic type coercion for scalar values and common collection formats
|
|
10
|
+
- Optional prefixes and case-insensitive matching
|
|
11
|
+
- File-backed secret fallback via `*_FILE` variables
|
|
12
|
+
- Declarative loading for top-level settings composed from prefixed blocks
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from typing import Optional
|
|
18
|
+
import msgspec
|
|
19
|
+
|
|
20
|
+
from msgspec_conf import BaseSettings
|
|
21
|
+
|
|
22
|
+
class AppSettings(BaseSettings):
|
|
23
|
+
debug: bool = False
|
|
24
|
+
database_url: str
|
|
25
|
+
api_key: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
settings = AppSettings.load(env_file=".env", prefix="APP_")
|
|
28
|
+
print(settings.database_url)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`.env` values override defaults, while real environment variables take precedence over the file.
|
|
32
|
+
|
|
33
|
+
## YAML and TOML config files
|
|
34
|
+
|
|
35
|
+
A config file can supply structured, non-secret configuration — the kind you
|
|
36
|
+
commit to git — while `.env` keeps private values out of the repository. The
|
|
37
|
+
two sources are independent: each has its own path and either can be used alone
|
|
38
|
+
or together. The file is parsed as TOML when it has a `.toml` suffix and as YAML
|
|
39
|
+
otherwise.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
settings = AppSettings.load(config_file="config.yaml", env_file=".env")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
# config.yaml — safe to commit
|
|
47
|
+
debug: false
|
|
48
|
+
host: api.example.com
|
|
49
|
+
tags:
|
|
50
|
+
- landing
|
|
51
|
+
- monitoring
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Values resolve with the precedence **real env vars > `.env` file > config file >
|
|
55
|
+
defaults**. So if `HOST` appears both in `config.yaml` and the environment, the
|
|
56
|
+
environment wins; fields only present in the config file are still applied.
|
|
57
|
+
|
|
58
|
+
The same applies to TOML. To read settings straight out of `pyproject.toml`,
|
|
59
|
+
point `config_file` at it and use `config_table` to select the table (a dotted
|
|
60
|
+
path) that holds your settings:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
settings = AppSettings.load(
|
|
64
|
+
config_file="pyproject.toml",
|
|
65
|
+
config_table="tool.myservice",
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```toml
|
|
70
|
+
# pyproject.toml
|
|
71
|
+
[tool.myservice]
|
|
72
|
+
debug = false
|
|
73
|
+
host = "api.example.com"
|
|
74
|
+
tags = ["landing", "monitoring"]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`config_table` works with any config file format; without it the whole document
|
|
78
|
+
is used. `load_composed_settings` accepts both `config_file` and `config_table`
|
|
79
|
+
as well, so each prefixed block can be configured from a nested table.
|
|
80
|
+
|
|
81
|
+
Because YAML is already structured, native types are used directly — no string
|
|
82
|
+
parsing is needed for lists, mappings, or nested records:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
limits:
|
|
86
|
+
checkout: 20
|
|
87
|
+
login: 60
|
|
88
|
+
rules:
|
|
89
|
+
- { id: 1, score: 95 }
|
|
90
|
+
- { id: 2, score: 90 }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Top-level YAML keys are matched against field names case-insensitively.
|
|
94
|
+
|
|
95
|
+
List values can be written as CSV, semicolon/newline-delimited text, or JSON:
|
|
96
|
+
|
|
97
|
+
```env
|
|
98
|
+
APP_ALLOWED_ORIGINS=https://example.com,https://api.example.com
|
|
99
|
+
APP_ALLOWED_LOCALES=en;uk;fr
|
|
100
|
+
APP_ALLOWED_TOPICS=["landing.live_stats","monitoring.live_status"]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Dict values can be written as JSON or delimited key-value pairs:
|
|
104
|
+
|
|
105
|
+
```env
|
|
106
|
+
APP_LIMITS={"checkout":20,"login":60}
|
|
107
|
+
APP_LIMITS=checkout=20,login:60;refresh=120
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`list[dict[...]]` values can be written as JSON or as records separated by
|
|
111
|
+
semicolon/newline. Within each record, comma separates key-value pairs:
|
|
112
|
+
|
|
113
|
+
```env
|
|
114
|
+
APP_RULES=[{"id":"policy.example","score":95,"evidence_urls":["https://example.com"]}]
|
|
115
|
+
APP_RULES=id=1,score=95;id=2,score=90
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Prefer JSON when values are deeply nested or may contain delimiters.
|
|
119
|
+
|
|
120
|
+
Boolean values are parsed strictly. Accepted values are `1/0`, `true/false`,
|
|
121
|
+
`yes/no`, `on/off`, and `y/n`.
|
|
122
|
+
|
|
123
|
+
## File-backed values
|
|
124
|
+
|
|
125
|
+
If `TOKEN` is empty or unset, `TOKEN_FILE` can point to a file containing the
|
|
126
|
+
value:
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
class SecretSettings(BaseSettings):
|
|
130
|
+
token: str = ""
|
|
131
|
+
token_file: str | None = None
|
|
132
|
+
|
|
133
|
+
settings = SecretSettings.load()
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```env
|
|
137
|
+
TOKEN_FILE=/run/secrets/api_token
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The `token_file` field is optional. `TOKEN_FILE` also works when only `token`
|
|
141
|
+
is defined.
|
|
142
|
+
|
|
143
|
+
## Composed settings
|
|
144
|
+
|
|
145
|
+
Services with a top-level `msgspec.Struct` can load nested settings blocks
|
|
146
|
+
declaratively:
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from msgspec_conf import BaseSettings, ServiceDefaultsBase, load_composed_settings
|
|
150
|
+
import msgspec
|
|
151
|
+
|
|
152
|
+
class DatabaseSettings(BaseSettings):
|
|
153
|
+
host: str = "localhost"
|
|
154
|
+
port: int = 5432
|
|
155
|
+
|
|
156
|
+
class ServiceDefaults(ServiceDefaultsBase):
|
|
157
|
+
service_name: str = "example-service"
|
|
158
|
+
|
|
159
|
+
class Settings(msgspec.Struct, kw_only=True):
|
|
160
|
+
debug: bool = False
|
|
161
|
+
service_name: str = "example-service"
|
|
162
|
+
database: DatabaseSettings = msgspec.field(default_factory=DatabaseSettings)
|
|
163
|
+
|
|
164
|
+
settings = load_composed_settings(
|
|
165
|
+
Settings,
|
|
166
|
+
env_file=".env",
|
|
167
|
+
defaults_cls=ServiceDefaults,
|
|
168
|
+
prefixes={"database": "POSTGRES_"},
|
|
169
|
+
)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Default factories on nested fields are preserved and then overridden by matching
|
|
173
|
+
environment values.
|
|
174
|
+
|
|
175
|
+
A YAML config file works here too. Top-level keys map to shared fields, while
|
|
176
|
+
each nested mapping (keyed by the block's field name) configures that block:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
settings = load_composed_settings(
|
|
180
|
+
Settings,
|
|
181
|
+
config_file="config.yaml",
|
|
182
|
+
env_file=".env",
|
|
183
|
+
defaults_cls=ServiceDefaults,
|
|
184
|
+
prefixes={"database": "POSTGRES_"},
|
|
185
|
+
)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
debug: false
|
|
190
|
+
service_name: example-service
|
|
191
|
+
database:
|
|
192
|
+
host: db.internal
|
|
193
|
+
port: 5432
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Environment variables (e.g. `POSTGRES_HOST`) still override the matching YAML
|
|
197
|
+
values.
|
|
198
|
+
|
|
199
|
+
## Installation
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
pip install msgspec-conf
|
|
203
|
+
# or
|
|
204
|
+
uv add msgspec-conf
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
## Development
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
uv sync --dev
|
|
211
|
+
uv run pytest
|
|
212
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "msgspec-conf"
|
|
3
|
+
version = "1.3.0"
|
|
4
|
+
description = "msgspec-based settings loader with env var, .env, YAML and TOML support"
|
|
5
|
+
authors = [{ name = "THEROER", email = "theroer09@gmail.com" }]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
requires-python = ">=3.11,<4"
|
|
9
|
+
keywords = ["msgspec", "settings", "configuration", "config", "dotenv", "yaml", "toml"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 5 - Production/Stable",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
15
|
+
"Typing :: Typed",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"msgspec>=0.21.0,<0.22",
|
|
19
|
+
"pyyaml>=6.0,<7",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/THEROER/env-settings"
|
|
24
|
+
Repository = "https://github.com/THEROER/env-settings"
|
|
25
|
+
Issues = "https://github.com/THEROER/env-settings/issues"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pre-commit>=4.6.0",
|
|
30
|
+
"pytest>=9.0.2,<10",
|
|
31
|
+
"pytest-cov>=7.0.0,<8",
|
|
32
|
+
"ruff>=0.15.5,<0.16",
|
|
33
|
+
"mypy>=1.19.1,<2",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.sdist]
|
|
37
|
+
include = ["src/msgspec_conf"]
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
include = ["src/msgspec_conf"]
|
|
41
|
+
|
|
42
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
43
|
+
"src/msgspec_conf" = "msgspec_conf"
|
|
44
|
+
|
|
45
|
+
[build-system]
|
|
46
|
+
requires = ["hatchling"]
|
|
47
|
+
build-backend = "hatchling.build"
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""msgspec-based settings loader with .env, YAML and TOML support."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
BaseSettings,
|
|
5
|
+
ServiceDefaultsBase,
|
|
6
|
+
load_composed_settings,
|
|
7
|
+
load_settings,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"BaseSettings",
|
|
12
|
+
"ServiceDefaultsBase",
|
|
13
|
+
"load_composed_settings",
|
|
14
|
+
"load_settings",
|
|
15
|
+
]
|
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
"""Core settings loading utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tomllib
|
|
9
|
+
import types
|
|
10
|
+
import typing
|
|
11
|
+
from typing import Any, Mapping, TypeVar, cast
|
|
12
|
+
from typing import get_args, get_origin, get_type_hints
|
|
13
|
+
|
|
14
|
+
import msgspec
|
|
15
|
+
import msgspec.yaml
|
|
16
|
+
|
|
17
|
+
_T = TypeVar("_T", bound="BaseSettings")
|
|
18
|
+
_S = TypeVar("_S")
|
|
19
|
+
|
|
20
|
+
_SENTINEL = object()
|
|
21
|
+
_TRUE_VALUES = {"1", "true", "t", "yes", "y", "on"}
|
|
22
|
+
_FALSE_VALUES = {"0", "false", "f", "no", "n", "off"}
|
|
23
|
+
_DEFAULT_DELIMITERS = {",", ";", "\n"}
|
|
24
|
+
_RECORD_DELIMITERS = {";", "\n"}
|
|
25
|
+
|
|
26
|
+
UNION_TYPES: set[Any] = {typing.Union}
|
|
27
|
+
if hasattr(types, "UnionType"):
|
|
28
|
+
UNION_TYPES.add(types.UnionType)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _parse_bool(value: str) -> bool:
|
|
32
|
+
normalized = value.strip().lower()
|
|
33
|
+
if normalized in _TRUE_VALUES:
|
|
34
|
+
return True
|
|
35
|
+
if normalized in _FALSE_VALUES:
|
|
36
|
+
return False
|
|
37
|
+
values = ", ".join(sorted(_TRUE_VALUES | _FALSE_VALUES))
|
|
38
|
+
msg = f"expected one of: {values}"
|
|
39
|
+
raise ValueError(msg)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _looks_like_json(raw: str) -> bool:
|
|
43
|
+
stripped = raw.strip()
|
|
44
|
+
return bool(stripped) and stripped[0] in "[{"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _strip_wrapping_quotes(raw: str) -> str:
|
|
48
|
+
value = raw.strip()
|
|
49
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
50
|
+
return value[1:-1]
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _strip_wrapping_brackets(raw: str, left: str, right: str) -> str:
|
|
55
|
+
value = raw.strip()
|
|
56
|
+
if len(value) >= 2 and value[0] == left and value[-1] == right:
|
|
57
|
+
return value[1:-1].strip()
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _decode_json(raw: str, expected_type: Any) -> Any: # noqa: ANN401
|
|
62
|
+
decoded = msgspec.json.decode(raw)
|
|
63
|
+
return _coerce_value(expected_type, decoded)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _try_decode_json(raw: str, expected_type: Any) -> Any: # noqa: ANN401
|
|
67
|
+
if not _looks_like_json(raw):
|
|
68
|
+
return _SENTINEL
|
|
69
|
+
try:
|
|
70
|
+
return _decode_json(raw, expected_type)
|
|
71
|
+
except msgspec.DecodeError:
|
|
72
|
+
return _SENTINEL
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _split_delimited(raw: str, delimiters: set[str]) -> list[str]:
|
|
76
|
+
items: list[str] = []
|
|
77
|
+
start = 0
|
|
78
|
+
quote: str | None = None
|
|
79
|
+
escaped = False
|
|
80
|
+
depth = 0
|
|
81
|
+
|
|
82
|
+
for index, char in enumerate(raw):
|
|
83
|
+
if escaped:
|
|
84
|
+
escaped = False
|
|
85
|
+
continue
|
|
86
|
+
if char == "\\":
|
|
87
|
+
escaped = True
|
|
88
|
+
continue
|
|
89
|
+
if quote:
|
|
90
|
+
if char == quote:
|
|
91
|
+
quote = None
|
|
92
|
+
continue
|
|
93
|
+
if char in {'"', "'"}:
|
|
94
|
+
quote = char
|
|
95
|
+
continue
|
|
96
|
+
if char in "[{(":
|
|
97
|
+
depth += 1
|
|
98
|
+
continue
|
|
99
|
+
if char in "]})" and depth > 0:
|
|
100
|
+
depth -= 1
|
|
101
|
+
continue
|
|
102
|
+
if depth == 0 and char in delimiters:
|
|
103
|
+
item = raw[start:index].strip()
|
|
104
|
+
if item:
|
|
105
|
+
items.append(item)
|
|
106
|
+
start = index + 1
|
|
107
|
+
|
|
108
|
+
item = raw[start:].strip()
|
|
109
|
+
if item:
|
|
110
|
+
items.append(item)
|
|
111
|
+
return items
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _find_pair_separator(raw: str) -> int:
|
|
115
|
+
quote: str | None = None
|
|
116
|
+
escaped = False
|
|
117
|
+
depth = 0
|
|
118
|
+
|
|
119
|
+
for index, char in enumerate(raw):
|
|
120
|
+
if escaped:
|
|
121
|
+
escaped = False
|
|
122
|
+
continue
|
|
123
|
+
if char == "\\":
|
|
124
|
+
escaped = True
|
|
125
|
+
continue
|
|
126
|
+
if quote:
|
|
127
|
+
if char == quote:
|
|
128
|
+
quote = None
|
|
129
|
+
continue
|
|
130
|
+
if char in {'"', "'"}:
|
|
131
|
+
quote = char
|
|
132
|
+
continue
|
|
133
|
+
if char in "[{(":
|
|
134
|
+
depth += 1
|
|
135
|
+
continue
|
|
136
|
+
if char in "]})" and depth > 0:
|
|
137
|
+
depth -= 1
|
|
138
|
+
continue
|
|
139
|
+
if depth == 0 and char in {"=", ":"}:
|
|
140
|
+
return index
|
|
141
|
+
return -1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_pair(raw: str) -> tuple[str, str]:
|
|
145
|
+
index = _find_pair_separator(raw)
|
|
146
|
+
if index <= 0:
|
|
147
|
+
msg = f"expected key=value or key:value pair, got {raw!r}"
|
|
148
|
+
raise ValueError(msg)
|
|
149
|
+
key = _strip_wrapping_quotes(raw[:index])
|
|
150
|
+
value = _strip_wrapping_quotes(raw[index + 1 :])
|
|
151
|
+
if not key:
|
|
152
|
+
msg = f"expected non-empty key in {raw!r}"
|
|
153
|
+
raise ValueError(msg)
|
|
154
|
+
return key, value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_mapping_type(expected_type: Any) -> bool: # noqa: ANN401
|
|
158
|
+
return expected_type is dict or get_origin(expected_type) is dict
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_mapping_entries(
|
|
162
|
+
expected_type: Any,
|
|
163
|
+
raw: str,
|
|
164
|
+
*,
|
|
165
|
+
delimiters: set[str],
|
|
166
|
+
) -> dict[Any, Any]:
|
|
167
|
+
args = get_args(expected_type)
|
|
168
|
+
key_type, value_type = args if len(args) == 2 else (str, Any)
|
|
169
|
+
value = _strip_wrapping_brackets(raw, "{", "}")
|
|
170
|
+
if not value:
|
|
171
|
+
return {}
|
|
172
|
+
|
|
173
|
+
result: dict[Any, Any] = {}
|
|
174
|
+
for entry in _split_delimited(value, delimiters):
|
|
175
|
+
key, item = _parse_pair(entry)
|
|
176
|
+
result[_coerce_value(key_type, key)] = _coerce_value(value_type, item)
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_mapping_records(expected_type: Any, raw: str) -> list[Any]:
|
|
181
|
+
args = get_args(expected_type)
|
|
182
|
+
item_type = args[0] if args else dict
|
|
183
|
+
value = _strip_wrapping_brackets(raw, "[", "]")
|
|
184
|
+
if not value:
|
|
185
|
+
return []
|
|
186
|
+
return [
|
|
187
|
+
_parse_mapping_entries(item_type, record, delimiters={","})
|
|
188
|
+
for record in _split_delimited(value, _RECORD_DELIMITERS)
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _coerce_mapping(expected_type: Any, raw: Any) -> Any: # noqa: ANN401
|
|
193
|
+
if isinstance(raw, str):
|
|
194
|
+
decoded = _try_decode_json(raw, expected_type)
|
|
195
|
+
if decoded is not _SENTINEL:
|
|
196
|
+
return decoded
|
|
197
|
+
return _parse_mapping_entries(
|
|
198
|
+
expected_type,
|
|
199
|
+
raw,
|
|
200
|
+
delimiters=_DEFAULT_DELIMITERS,
|
|
201
|
+
)
|
|
202
|
+
if not isinstance(raw, Mapping):
|
|
203
|
+
return raw
|
|
204
|
+
|
|
205
|
+
args = get_args(expected_type)
|
|
206
|
+
if len(args) != 2:
|
|
207
|
+
return raw
|
|
208
|
+
key_type, value_type = args
|
|
209
|
+
return {
|
|
210
|
+
_coerce_value(key_type, key): _coerce_value(value_type, value)
|
|
211
|
+
for key, value in raw.items()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _coerce_sequence(expected_type: Any, raw: Any) -> Any: # noqa: ANN401
|
|
216
|
+
origin = get_origin(expected_type)
|
|
217
|
+
args = get_args(expected_type)
|
|
218
|
+
item_type = args[0] if args else Any
|
|
219
|
+
|
|
220
|
+
if isinstance(raw, str):
|
|
221
|
+
decoded = _try_decode_json(raw, expected_type)
|
|
222
|
+
if decoded is not _SENTINEL:
|
|
223
|
+
return decoded
|
|
224
|
+
if _is_mapping_type(item_type):
|
|
225
|
+
coerced = _parse_mapping_records(expected_type, raw)
|
|
226
|
+
else:
|
|
227
|
+
value = _strip_wrapping_brackets(raw, "[", "]")
|
|
228
|
+
items = _split_delimited(value, _DEFAULT_DELIMITERS)
|
|
229
|
+
coerced = [_coerce_value(item_type, item) for item in items]
|
|
230
|
+
elif isinstance(raw, list | tuple | set):
|
|
231
|
+
coerced = [_coerce_value(item_type, item) for item in raw]
|
|
232
|
+
else:
|
|
233
|
+
return raw
|
|
234
|
+
|
|
235
|
+
if origin is tuple:
|
|
236
|
+
return tuple(coerced)
|
|
237
|
+
if origin is set:
|
|
238
|
+
return set(coerced)
|
|
239
|
+
return coerced
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _coerce_union(expected_type: Any, raw: Any) -> Any: # noqa: ANN401
|
|
243
|
+
args = get_args(expected_type)
|
|
244
|
+
if type(None) in args:
|
|
245
|
+
if isinstance(raw, str) and raw == "":
|
|
246
|
+
return None
|
|
247
|
+
non_none = [arg for arg in args if arg is not type(None)]
|
|
248
|
+
if len(non_none) == 1:
|
|
249
|
+
return _coerce_value(non_none[0], raw)
|
|
250
|
+
|
|
251
|
+
last_error: Exception | None = None
|
|
252
|
+
for arg in args:
|
|
253
|
+
try:
|
|
254
|
+
return _coerce_value(arg, raw)
|
|
255
|
+
except (TypeError, ValueError, msgspec.ValidationError) as exc:
|
|
256
|
+
last_error = exc
|
|
257
|
+
if last_error is not None:
|
|
258
|
+
raise last_error
|
|
259
|
+
return raw
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _coerce_value(expected_type: Any, raw: Any) -> Any: # noqa: ANN401
|
|
263
|
+
origin = get_origin(expected_type)
|
|
264
|
+
if origin is None:
|
|
265
|
+
if not isinstance(raw, str):
|
|
266
|
+
return raw
|
|
267
|
+
if expected_type is str:
|
|
268
|
+
return raw
|
|
269
|
+
if expected_type is int:
|
|
270
|
+
return int(raw)
|
|
271
|
+
if expected_type is float:
|
|
272
|
+
return float(raw)
|
|
273
|
+
if expected_type is bool:
|
|
274
|
+
return _parse_bool(raw)
|
|
275
|
+
return raw
|
|
276
|
+
|
|
277
|
+
if origin in {list, tuple, set}:
|
|
278
|
+
return _coerce_sequence(expected_type, raw)
|
|
279
|
+
|
|
280
|
+
if origin is dict:
|
|
281
|
+
return _coerce_mapping(expected_type, raw)
|
|
282
|
+
|
|
283
|
+
if origin in UNION_TYPES:
|
|
284
|
+
return _coerce_union(expected_type, raw)
|
|
285
|
+
|
|
286
|
+
return raw
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _is_yaml_path(path: str | os.PathLike[str]) -> bool:
|
|
290
|
+
return Path(path).suffix.lower() in {".yaml", ".yml"}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _is_toml_path(path: str | os.PathLike[str]) -> bool:
|
|
294
|
+
return Path(path).suffix.lower() == ".toml"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _decode_config_file(path: Path) -> Any: # noqa: ANN401
|
|
298
|
+
if _is_toml_path(path):
|
|
299
|
+
return tomllib.loads(path.read_text())
|
|
300
|
+
return msgspec.yaml.decode(path.read_bytes())
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _select_config_table(
|
|
304
|
+
decoded: Mapping[str, Any],
|
|
305
|
+
table: str,
|
|
306
|
+
*,
|
|
307
|
+
path: Path,
|
|
308
|
+
) -> dict[str, Any]:
|
|
309
|
+
node: Any = decoded
|
|
310
|
+
for part in table.split("."):
|
|
311
|
+
if not isinstance(node, Mapping) or part not in node:
|
|
312
|
+
return {}
|
|
313
|
+
node = node[part]
|
|
314
|
+
if not isinstance(node, Mapping):
|
|
315
|
+
msg = f"config table {table!r} in {path} must be a mapping"
|
|
316
|
+
raise msgspec.ValidationError(msg)
|
|
317
|
+
return dict(node)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _parse_config_file(path: Path, *, table: str | None = None) -> dict[str, Any]:
|
|
321
|
+
if not path.exists():
|
|
322
|
+
return {}
|
|
323
|
+
decoded = _decode_config_file(path)
|
|
324
|
+
if decoded is None:
|
|
325
|
+
return {}
|
|
326
|
+
if not isinstance(decoded, Mapping):
|
|
327
|
+
msg = f"config file {path} must contain a mapping at the top level"
|
|
328
|
+
raise msgspec.ValidationError(msg)
|
|
329
|
+
if table:
|
|
330
|
+
return _select_config_table(decoded, table, path=path)
|
|
331
|
+
return dict(decoded)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _resolve_config_field(
|
|
335
|
+
key: str,
|
|
336
|
+
*,
|
|
337
|
+
fields: Mapping[str, str],
|
|
338
|
+
case_sensitive: bool,
|
|
339
|
+
) -> str | None:
|
|
340
|
+
if case_sensitive:
|
|
341
|
+
return fields.get(key)
|
|
342
|
+
return fields.get(key.upper()) or fields.get(key)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
346
|
+
data: dict[str, str] = {}
|
|
347
|
+
if not path.exists():
|
|
348
|
+
return data
|
|
349
|
+
for raw_line in path.read_text().splitlines():
|
|
350
|
+
line = raw_line.strip()
|
|
351
|
+
if not line or line.startswith("#"):
|
|
352
|
+
continue
|
|
353
|
+
if "=" not in line:
|
|
354
|
+
continue
|
|
355
|
+
key, value = line.split("=", 1)
|
|
356
|
+
key = key.strip()
|
|
357
|
+
value = value.strip()
|
|
358
|
+
if value and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
359
|
+
value = value[1:-1]
|
|
360
|
+
data[key] = value
|
|
361
|
+
return data
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _field_lookup(
|
|
365
|
+
cls: type[Any],
|
|
366
|
+
*,
|
|
367
|
+
case_sensitive: bool,
|
|
368
|
+
) -> dict[str, str]:
|
|
369
|
+
return {
|
|
370
|
+
name if case_sensitive else name.upper(): name
|
|
371
|
+
for name in cls.__struct_fields__
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _struct_fields(cls: type[Any]) -> tuple[str, ...]:
|
|
376
|
+
return tuple(getattr(cls, "__struct_fields__", ()))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _resolve_field(
|
|
380
|
+
key: str,
|
|
381
|
+
*,
|
|
382
|
+
fields: Mapping[str, str],
|
|
383
|
+
prefix: str | None,
|
|
384
|
+
case_sensitive: bool,
|
|
385
|
+
allow_prefix: bool,
|
|
386
|
+
) -> str | None:
|
|
387
|
+
key_cmp = key if case_sensitive else key.upper()
|
|
388
|
+
prefix_cmp = prefix if case_sensitive else (prefix.upper() if prefix else None)
|
|
389
|
+
if allow_prefix and prefix_cmp:
|
|
390
|
+
if not key_cmp.startswith(prefix_cmp):
|
|
391
|
+
return None
|
|
392
|
+
key_cmp = key_cmp[len(prefix_cmp) :]
|
|
393
|
+
return fields.get(key_cmp) or (fields.get(key) if not allow_prefix else None)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _resolve_file_field(
|
|
397
|
+
key: str,
|
|
398
|
+
*,
|
|
399
|
+
fields: Mapping[str, str],
|
|
400
|
+
prefix: str | None,
|
|
401
|
+
case_sensitive: bool,
|
|
402
|
+
) -> str | None:
|
|
403
|
+
key_cmp = key if case_sensitive else key.upper()
|
|
404
|
+
prefix_cmp = prefix if case_sensitive else (prefix.upper() if prefix else None)
|
|
405
|
+
if prefix_cmp:
|
|
406
|
+
if not key_cmp.startswith(prefix_cmp):
|
|
407
|
+
return None
|
|
408
|
+
key_cmp = key_cmp[len(prefix_cmp) :]
|
|
409
|
+
if not key_cmp.endswith("_FILE"):
|
|
410
|
+
return None
|
|
411
|
+
return fields.get(key_cmp[: -len("_FILE")])
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _has_value(value: Any) -> bool: # noqa: ANN401
|
|
415
|
+
return value is not None and value != ""
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _read_file_value(path: str | os.PathLike[str], *, field: str) -> str:
|
|
419
|
+
file_path = Path(path)
|
|
420
|
+
try:
|
|
421
|
+
return file_path.read_text().rstrip("\r\n")
|
|
422
|
+
except OSError as exc:
|
|
423
|
+
msg = f"unable to read file value for {field}: {file_path}"
|
|
424
|
+
raise msgspec.ValidationError(msg) from exc
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _apply_file_values(
|
|
428
|
+
result: dict[str, Any],
|
|
429
|
+
file_refs: Mapping[str, Any],
|
|
430
|
+
fields: Mapping[str, str],
|
|
431
|
+
) -> None:
|
|
432
|
+
field_names = set(fields.values())
|
|
433
|
+
for field in field_names:
|
|
434
|
+
if field.endswith("_file") or _has_value(result.get(field)):
|
|
435
|
+
continue
|
|
436
|
+
|
|
437
|
+
file_value = file_refs.get(field)
|
|
438
|
+
file_field = f"{field}_file"
|
|
439
|
+
if file_value is None and file_field in result:
|
|
440
|
+
file_value = result[file_field]
|
|
441
|
+
if not _has_value(file_value):
|
|
442
|
+
continue
|
|
443
|
+
if not isinstance(file_value, str | os.PathLike):
|
|
444
|
+
msg = f"file reference for {field} must be a path"
|
|
445
|
+
raise msgspec.ValidationError(msg)
|
|
446
|
+
|
|
447
|
+
result[field] = _read_file_value(file_value, field=field)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _coerce_collected(
|
|
451
|
+
cls: type[Any],
|
|
452
|
+
raw: dict[str, Any],
|
|
453
|
+
*,
|
|
454
|
+
prefix: str | None,
|
|
455
|
+
case_sensitive: bool,
|
|
456
|
+
) -> dict[str, Any]:
|
|
457
|
+
if not raw:
|
|
458
|
+
return raw
|
|
459
|
+
|
|
460
|
+
type_hints = get_type_hints(cls, include_extras=True)
|
|
461
|
+
for name, hinted_type in type_hints.items():
|
|
462
|
+
if name not in raw:
|
|
463
|
+
continue
|
|
464
|
+
try:
|
|
465
|
+
raw[name] = _coerce_value(hinted_type, raw[name])
|
|
466
|
+
except (TypeError, ValueError, msgspec.ValidationError) as exc:
|
|
467
|
+
env_name = name if case_sensitive else name.upper()
|
|
468
|
+
if prefix:
|
|
469
|
+
env_name = f"{prefix}{env_name}"
|
|
470
|
+
msg = f"invalid value for {env_name} ({cls.__name__}.{name}): {exc}"
|
|
471
|
+
raise msgspec.ValidationError(msg) from exc
|
|
472
|
+
return raw
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _struct_defaults(instance: Any) -> dict[str, Any]: # noqa: ANN401
|
|
476
|
+
if instance is None:
|
|
477
|
+
return {}
|
|
478
|
+
return msgspec.to_builtins(instance)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _is_settings_type(value: Any) -> bool: # noqa: ANN401
|
|
482
|
+
return isinstance(value, type) and issubclass(value, BaseSettings)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _default_instance(cls: type[_S]) -> _S | None:
|
|
486
|
+
try:
|
|
487
|
+
return cls()
|
|
488
|
+
except (TypeError, msgspec.ValidationError):
|
|
489
|
+
return None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class BaseSettings(msgspec.Struct, kw_only=True, omit_defaults=True):
|
|
493
|
+
"""Base class for settings definitions."""
|
|
494
|
+
|
|
495
|
+
@classmethod
|
|
496
|
+
def _collect_env(
|
|
497
|
+
cls,
|
|
498
|
+
*,
|
|
499
|
+
env: Mapping[str, str] | None,
|
|
500
|
+
env_file: str | os.PathLike[str] | None,
|
|
501
|
+
prefix: str | None,
|
|
502
|
+
case_sensitive: bool,
|
|
503
|
+
defaults: Mapping[str, Any] | None,
|
|
504
|
+
config: Mapping[str, Any] | None,
|
|
505
|
+
) -> dict[str, Any]:
|
|
506
|
+
result: dict[str, Any] = {}
|
|
507
|
+
file_refs: dict[str, Any] = {}
|
|
508
|
+
|
|
509
|
+
fields = _field_lookup(cls, case_sensitive=case_sensitive)
|
|
510
|
+
|
|
511
|
+
if defaults:
|
|
512
|
+
for key, value in defaults.items():
|
|
513
|
+
field = _resolve_field(
|
|
514
|
+
key,
|
|
515
|
+
fields=fields,
|
|
516
|
+
prefix=prefix,
|
|
517
|
+
case_sensitive=case_sensitive,
|
|
518
|
+
allow_prefix=False,
|
|
519
|
+
)
|
|
520
|
+
if field:
|
|
521
|
+
result[field] = value
|
|
522
|
+
|
|
523
|
+
if config:
|
|
524
|
+
for key, value in config.items():
|
|
525
|
+
field = _resolve_config_field(
|
|
526
|
+
key,
|
|
527
|
+
fields=fields,
|
|
528
|
+
case_sensitive=case_sensitive,
|
|
529
|
+
)
|
|
530
|
+
if field:
|
|
531
|
+
result[field] = value
|
|
532
|
+
|
|
533
|
+
if env_file:
|
|
534
|
+
env_path = Path(env_file)
|
|
535
|
+
file_values = _parse_env_file(env_path)
|
|
536
|
+
for key, value in file_values.items():
|
|
537
|
+
field = _resolve_field(
|
|
538
|
+
key,
|
|
539
|
+
fields=fields,
|
|
540
|
+
prefix=prefix,
|
|
541
|
+
case_sensitive=case_sensitive,
|
|
542
|
+
allow_prefix=True,
|
|
543
|
+
)
|
|
544
|
+
if field:
|
|
545
|
+
result[field] = value
|
|
546
|
+
continue
|
|
547
|
+
file_field = _resolve_file_field(
|
|
548
|
+
key,
|
|
549
|
+
fields=fields,
|
|
550
|
+
prefix=prefix,
|
|
551
|
+
case_sensitive=case_sensitive,
|
|
552
|
+
)
|
|
553
|
+
if file_field:
|
|
554
|
+
file_refs[file_field] = value
|
|
555
|
+
|
|
556
|
+
source = env or os.environ
|
|
557
|
+
for key, value in source.items():
|
|
558
|
+
field = _resolve_field(
|
|
559
|
+
key,
|
|
560
|
+
fields=fields,
|
|
561
|
+
prefix=prefix,
|
|
562
|
+
case_sensitive=case_sensitive,
|
|
563
|
+
allow_prefix=True,
|
|
564
|
+
)
|
|
565
|
+
if field:
|
|
566
|
+
result[field] = value
|
|
567
|
+
continue
|
|
568
|
+
file_field = _resolve_file_field(
|
|
569
|
+
key,
|
|
570
|
+
fields=fields,
|
|
571
|
+
prefix=prefix,
|
|
572
|
+
case_sensitive=case_sensitive,
|
|
573
|
+
)
|
|
574
|
+
if file_field:
|
|
575
|
+
file_refs[file_field] = value
|
|
576
|
+
|
|
577
|
+
_apply_file_values(result, file_refs, fields)
|
|
578
|
+
|
|
579
|
+
return result
|
|
580
|
+
|
|
581
|
+
@classmethod
|
|
582
|
+
def load(
|
|
583
|
+
cls: type[_T],
|
|
584
|
+
*,
|
|
585
|
+
env: Mapping[str, str] | None = None,
|
|
586
|
+
env_file: str | os.PathLike[str] | None = None,
|
|
587
|
+
config_file: str | os.PathLike[str] | None = None,
|
|
588
|
+
config_table: str | None = None,
|
|
589
|
+
prefix: str | None = None,
|
|
590
|
+
case_sensitive: bool = False,
|
|
591
|
+
defaults: Mapping[str, Any] | None = None,
|
|
592
|
+
config: Mapping[str, Any] | None = None,
|
|
593
|
+
) -> _T:
|
|
594
|
+
"""Create settings instance from environment data.
|
|
595
|
+
|
|
596
|
+
Values are resolved with the precedence ``env`` > ``env_file`` (.env)
|
|
597
|
+
> ``config_file`` (YAML or TOML) > ``defaults``. ``config_file`` is
|
|
598
|
+
parsed as TOML when it has a ``.toml`` suffix and as YAML otherwise.
|
|
599
|
+
``config_table`` selects a nested table from the file by dotted path
|
|
600
|
+
(e.g. ``"tool.myservice"`` to read settings from ``pyproject.toml``).
|
|
601
|
+
``config`` accepts an already-parsed mapping (e.g. a section of a
|
|
602
|
+
larger document) and is merged at the same level as ``config_file``.
|
|
603
|
+
"""
|
|
604
|
+
|
|
605
|
+
if config_file is not None:
|
|
606
|
+
file_config = _parse_config_file(Path(config_file), table=config_table)
|
|
607
|
+
config = {**file_config, **config} if config else file_config
|
|
608
|
+
|
|
609
|
+
raw = cls._collect_env(
|
|
610
|
+
env=env,
|
|
611
|
+
env_file=env_file,
|
|
612
|
+
prefix=prefix,
|
|
613
|
+
case_sensitive=case_sensitive,
|
|
614
|
+
defaults=defaults,
|
|
615
|
+
config=config,
|
|
616
|
+
)
|
|
617
|
+
raw = _coerce_collected(
|
|
618
|
+
cls,
|
|
619
|
+
raw,
|
|
620
|
+
prefix=prefix,
|
|
621
|
+
case_sensitive=case_sensitive,
|
|
622
|
+
)
|
|
623
|
+
try:
|
|
624
|
+
return msgspec.convert(raw, cls)
|
|
625
|
+
except msgspec.ValidationError as exc:
|
|
626
|
+
msg = f"invalid settings for {cls.__name__}: {exc}"
|
|
627
|
+
raise msgspec.ValidationError(msg) from exc
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
class ServiceDefaultsBase(BaseSettings):
|
|
631
|
+
"""Common top-level service defaults used across microservices."""
|
|
632
|
+
|
|
633
|
+
debug: bool = False
|
|
634
|
+
sqlalchemy_echo: bool = False
|
|
635
|
+
service_name: str = "service"
|
|
636
|
+
public_base_url: str = "https://leavelocal.com"
|
|
637
|
+
cors_allow_origins: list[str] = msgspec.field(default_factory=list)
|
|
638
|
+
cors_allow_origins_debug: list[str] = msgspec.field(default_factory=list)
|
|
639
|
+
snowflake_worker_id: int = 0
|
|
640
|
+
snowflake_datacenter_id: int = 0
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def load_settings(
|
|
644
|
+
cls: type[_T],
|
|
645
|
+
*,
|
|
646
|
+
env: Mapping[str, str] | None = None,
|
|
647
|
+
env_file: str | os.PathLike[str] | None = None,
|
|
648
|
+
config_file: str | os.PathLike[str] | None = None,
|
|
649
|
+
config_table: str | None = None,
|
|
650
|
+
prefix: str | None = None,
|
|
651
|
+
case_sensitive: bool = False,
|
|
652
|
+
defaults: Mapping[str, Any] | None = None,
|
|
653
|
+
) -> _T:
|
|
654
|
+
"""Convenience helper to instantiate settings."""
|
|
655
|
+
|
|
656
|
+
if not issubclass(cls, BaseSettings):
|
|
657
|
+
msg = "cls must derive from BaseSettings"
|
|
658
|
+
raise TypeError(msg)
|
|
659
|
+
return cls.load(
|
|
660
|
+
env=env,
|
|
661
|
+
env_file=env_file,
|
|
662
|
+
config_file=config_file,
|
|
663
|
+
config_table=config_table,
|
|
664
|
+
prefix=prefix,
|
|
665
|
+
case_sensitive=case_sensitive,
|
|
666
|
+
defaults=defaults,
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def load_composed_settings(
|
|
671
|
+
cls: type[_S],
|
|
672
|
+
*,
|
|
673
|
+
env: Mapping[str, str] | None = None,
|
|
674
|
+
env_file: str | os.PathLike[str] | None = None,
|
|
675
|
+
config_file: str | os.PathLike[str] | None = None,
|
|
676
|
+
config_table: str | None = None,
|
|
677
|
+
prefixes: Mapping[str, str] | None = None,
|
|
678
|
+
defaults_cls: type[BaseSettings] | None = None,
|
|
679
|
+
case_sensitive: bool = False,
|
|
680
|
+
post_load: Callable[[_S], _S] | None = None,
|
|
681
|
+
) -> _S:
|
|
682
|
+
"""Load a top-level ``msgspec.Struct`` composed from settings blocks.
|
|
683
|
+
|
|
684
|
+
When ``config_file`` is given, top-level keys map to shared fields while
|
|
685
|
+
each nested mapping (keyed by block field name) configures the matching
|
|
686
|
+
settings block. The file is parsed as TOML for a ``.toml`` suffix and as
|
|
687
|
+
YAML otherwise; ``config_table`` selects a nested table by dotted path
|
|
688
|
+
(e.g. ``"tool.myservice"`` for ``pyproject.toml``).
|
|
689
|
+
"""
|
|
690
|
+
|
|
691
|
+
config: dict[str, Any] = {}
|
|
692
|
+
if config_file is not None:
|
|
693
|
+
config = _parse_config_file(Path(config_file), table=config_table)
|
|
694
|
+
|
|
695
|
+
values: dict[str, Any] = {}
|
|
696
|
+
base_values: dict[str, Any] = {}
|
|
697
|
+
base = _default_instance(cls)
|
|
698
|
+
struct_fields = _struct_fields(cls)
|
|
699
|
+
if base is not None:
|
|
700
|
+
base_values = {field: getattr(base, field) for field in struct_fields}
|
|
701
|
+
|
|
702
|
+
if defaults_cls is not None:
|
|
703
|
+
defaults = defaults_cls.load(
|
|
704
|
+
env=env,
|
|
705
|
+
env_file=env_file,
|
|
706
|
+
case_sensitive=case_sensitive,
|
|
707
|
+
config=config or None,
|
|
708
|
+
)
|
|
709
|
+
for field in defaults_cls.__struct_fields__:
|
|
710
|
+
if field in struct_fields:
|
|
711
|
+
values[field] = getattr(defaults, field)
|
|
712
|
+
|
|
713
|
+
try:
|
|
714
|
+
type_hints = get_type_hints(cls, include_extras=True)
|
|
715
|
+
except NameError:
|
|
716
|
+
type_hints = dict(getattr(cls, "__annotations__", {}))
|
|
717
|
+
|
|
718
|
+
for field, prefix in (prefixes or {}).items():
|
|
719
|
+
settings_type = type_hints.get(field)
|
|
720
|
+
if not _is_settings_type(settings_type):
|
|
721
|
+
base_value = base_values.get(field)
|
|
722
|
+
if isinstance(base_value, BaseSettings):
|
|
723
|
+
settings_type = type(base_value)
|
|
724
|
+
if not _is_settings_type(settings_type):
|
|
725
|
+
msg = f"{cls.__name__}.{field} must be a BaseSettings field"
|
|
726
|
+
raise TypeError(msg)
|
|
727
|
+
settings_cls = cast(type[BaseSettings], settings_type)
|
|
728
|
+
default_values = _struct_defaults(base_values.get(field))
|
|
729
|
+
section = config.get(field)
|
|
730
|
+
block_config = section if isinstance(section, Mapping) else None
|
|
731
|
+
values[field] = settings_cls.load(
|
|
732
|
+
env=env,
|
|
733
|
+
env_file=env_file,
|
|
734
|
+
prefix=prefix,
|
|
735
|
+
case_sensitive=case_sensitive,
|
|
736
|
+
defaults=default_values,
|
|
737
|
+
config=block_config,
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
try:
|
|
741
|
+
settings = cls(**values)
|
|
742
|
+
except (TypeError, msgspec.ValidationError) as exc:
|
|
743
|
+
msg = f"invalid composed settings for {cls.__name__}: {exc}"
|
|
744
|
+
raise msgspec.ValidationError(msg) from exc
|
|
745
|
+
|
|
746
|
+
if post_load is not None:
|
|
747
|
+
return post_load(settings)
|
|
748
|
+
return settings
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|