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.
@@ -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