Skip to content

Commit 9c5d42a

Browse files
feat: add array mapping
1 parent 6f4cff3 commit 9c5d42a

9 files changed

Lines changed: 554 additions & 11 deletions

File tree

README.md

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ settings = (
3535
- `.env` file support
3636
- Typed validation with Pydantic v2
3737
- Compatible with `dict[str, SubModel]`, lists, booleans, integers, etc.
38+
- Direct path access with `config["section:key"]`
39+
- Typed subsections with `.get_section("...").get(MyModel)`
3840

3941
## Installation
4042

@@ -60,6 +62,16 @@ from pydantic import Field
6062
from axa_fr_app_settings import SettingsModel
6163

6264

65+
class EndpointSettings(SettingsModel):
66+
name: str
67+
url: str
68+
69+
70+
class RegionSettings(SettingsModel):
71+
name: str
72+
endpoints: list[EndpointSettings] = Field(default_factory=list)
73+
74+
6375
class OIDCSettings(SettingsModel):
6476
endpoint_url: str
6577
issuer: str
@@ -91,6 +103,8 @@ class AppSettings(SettingsModel):
91103
http_timeout: int = 45
92104
http_verify: bool = False
93105
cache: CacheSettings = Field(default_factory=CacheSettings)
106+
allowed_hosts: list[str] = Field(default_factory=list)
107+
regions: list[RegionSettings] = Field(default_factory=list)
94108
```
95109

96110
### 2. Build the configuration
@@ -121,18 +135,43 @@ export DEBUG=true
121135
export HTTP_TIMEOUT=30
122136
export CACHE__REDIS__EXPIRY_TIME=120
123137
export DATABASE__main__ENDPOINT_URL="postgresql://localhost:5432/app"
138+
export ALLOWED_HOSTS__0="api.local"
139+
export ALLOWED_HOSTS__1="admin.local"
140+
export REGIONS__0__NAME="eu-west"
141+
export REGIONS__0__ENDPOINTS__0__NAME="catalog"
142+
export REGIONS__0__ENDPOINTS__0__URL="https://eu-west/catalog"
143+
export REGIONS__0__ENDPOINTS__1__NAME="orders"
144+
export REGIONS__0__ENDPOINTS__1__URL="https://eu-west/orders"
145+
export REGIONS__1__NAME="us-east"
146+
export REGIONS__1__ENDPOINTS__0__NAME="catalog"
147+
export REGIONS__1__ENDPOINTS__0__URL="https://us-east/catalog"
124148
```
125149

126150
### 4. YAML example
127151

128152
```yaml
129153
debug: false
130154
http_timeout: 45
155+
allowed_hosts:
156+
- api.local
157+
- admin.local
131158

132159
database:
133160
main:
134161
endpoint_url: "postgresql://localhost:5432/app"
135162

163+
regions:
164+
- name: eu-west
165+
endpoints:
166+
- name: catalog
167+
url: "https://eu-west/catalog"
168+
- name: orders
169+
url: "https://eu-west/orders"
170+
- name: us-east
171+
endpoints:
172+
- name: catalog
173+
url: "https://us-east/catalog"
174+
136175
cache:
137176
type: redis
138177
redis:
@@ -147,11 +186,36 @@ cache:
147186
{
148187
"debug": false,
149188
"http_timeout": 45,
189+
"allowed_hosts": ["api.local", "admin.local"],
150190
"database": {
151191
"main": {
152192
"endpoint_url": "postgresql://localhost:5432/app"
153193
}
154194
},
195+
"regions": [
196+
{
197+
"name": "eu-west",
198+
"endpoints": [
199+
{
200+
"name": "catalog",
201+
"url": "https://eu-west/catalog"
202+
},
203+
{
204+
"name": "orders",
205+
"url": "https://eu-west/orders"
206+
}
207+
]
208+
},
209+
{
210+
"name": "us-east",
211+
"endpoints": [
212+
{
213+
"name": "catalog",
214+
"url": "https://us-east/catalog"
215+
}
216+
]
217+
}
218+
],
155219
"cache": {
156220
"type": "redis",
157221
"redis": {
@@ -165,6 +229,48 @@ cache:
165229

166230
YAML and JSON sources can be mixed freely. The last source added always wins.
167231

232+
## Arrays and nested arrays
233+
234+
Arrays are supported in file sources (`yaml`, `json`) and also in flat sources
235+
like environment variables and `.env` files.
236+
237+
### Simple array
238+
239+
```yaml
240+
allowed_hosts:
241+
- api.local
242+
- admin.local
243+
```
244+
245+
### Nested array with `regions`
246+
247+
```yaml
248+
regions:
249+
- name: eu-west
250+
endpoints:
251+
- name: catalog
252+
url: https://eu-west/catalog
253+
- name: orders
254+
url: https://eu-west/orders
255+
- name: us-east
256+
endpoints:
257+
- name: catalog
258+
url: https://us-east/catalog
259+
```
260+
261+
Equivalent environment variables:
262+
263+
```bash
264+
export REGIONS__0__NAME="eu-west"
265+
export REGIONS__0__ENDPOINTS__0__NAME="catalog"
266+
export REGIONS__0__ENDPOINTS__0__URL="https://eu-west/catalog"
267+
export REGIONS__0__ENDPOINTS__1__NAME="orders"
268+
export REGIONS__0__ENDPOINTS__1__URL="https://eu-west/orders"
269+
export REGIONS__1__NAME="us-east"
270+
export REGIONS__1__ENDPOINTS__0__NAME="catalog"
271+
export REGIONS__1__ENDPOINTS__0__URL="https://us-east/catalog"
272+
```
273+
168274
## Priority order
169275

170276
As in .NET, **the last source added wins**.
@@ -198,6 +304,7 @@ Here:
198304
| `add_source(source)` | Add a custom source (any object with a `load()` method) |
199305
| `build()` | Build and return the validated settings model |
200306
| `build_data()` | Build and return the raw merged dict |
307+
| `build_configuration()` | Build and return a navigable configuration root |
201308
| `build_watched(*, debounce_seconds=0.3, polling_interval_seconds=None)` | Build and return a `SettingsWatcher` with auto-reload |
202309

203310
**Key parameters:**
@@ -353,9 +460,83 @@ The Key Vault source (or any custom source) follows the same priority rule: **th
353460

354461
A complete example is available in [`examples/custom_keyvault_source.py`](examples/custom_keyvault_source.py).
355462

463+
## Direct key access and typed sections
464+
465+
Like in .NET, you can also work with the raw merged configuration tree.
466+
This is useful when you want direct path access or when you only want to bind
467+
one subsection.
468+
469+
```python
470+
from pydantic import Field
471+
472+
from axa_fr_app_settings import ConfigurationBuilder, SettingsModel
473+
474+
475+
class EndpointSettings(SettingsModel):
476+
name: str
477+
url: str
478+
479+
480+
class RegionSettings(SettingsModel):
481+
name: str
482+
endpoints: list[EndpointSettings] = Field(default_factory=list)
483+
484+
485+
class AppSettings(SettingsModel):
486+
application_name: str
487+
max_users: int
488+
feature_toggle: bool = False
489+
allowed_hosts: list[str] = Field(default_factory=list)
490+
regions: list[RegionSettings] = Field(default_factory=list)
491+
492+
493+
class RootSettings(SettingsModel):
494+
appsettings: AppSettings
495+
496+
497+
config = (
498+
ConfigurationBuilder(RootSettings)
499+
.add_json_file("appsettings.json")
500+
.build_configuration()
501+
)
502+
503+
app_name = config["appsettings:application_name"]
504+
max_users = config["appsettings:max_users"]
505+
first_region = config["appsettings:regions:0:name"]
506+
first_endpoint_url = config["appsettings:regions:0:endpoints:0:url"]
507+
508+
app_settings = config.get_section("appsettings").get(AppSettings)
509+
print(f"FeatureToggle: {app_settings.feature_toggle}")
510+
print(f"Application Name: {app_settings.application_name}")
511+
print(f"Max Users: {app_settings.max_users}")
512+
print(f"First Region: {first_region}")
513+
print(f"First Endpoint URL: {first_endpoint_url}")
514+
```
515+
516+
You can also bind the full root model directly:
517+
518+
```python
519+
root_settings = config.bind()
520+
```
521+
522+
For environment variables or `.env` files, use numeric indexes with `__`:
523+
524+
```bash
525+
export APPSETTINGS__ALLOWED_HOSTS__0=api.local
526+
export APPSETTINGS__ALLOWED_HOSTS__1=admin.local
527+
export APPSETTINGS__REGIONS__0__NAME="eu-west"
528+
export APPSETTINGS__REGIONS__0__ENDPOINTS__0__NAME="catalog"
529+
export APPSETTINGS__REGIONS__0__ENDPOINTS__0__URL="https://eu/catalog"
530+
```
531+
532+
A complete runnable example is available in [`examples/configuration_sections.py`](examples/configuration_sections.py).
533+
356534
## Full example
357535

358-
A complete example is provided in [`examples/api_settings.py`](examples/api_settings.py).
536+
Complete examples are provided in:
537+
538+
- [`examples/api_settings.py`](examples/api_settings.py)
539+
- [`examples/configuration_sections.py`](examples/configuration_sections.py)
359540

360541
## Publishing to PyPI with GitHub Actions
361542

examples/configuration_sections.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from pydantic import Field
4+
5+
from axa_fr_app_settings import ConfigurationBuilder, SettingsModel
6+
7+
8+
class EndpointSettings(SettingsModel):
9+
name: str
10+
url: str
11+
12+
13+
class RegionSettings(SettingsModel):
14+
name: str
15+
endpoints: list[EndpointSettings] = Field(default_factory=list)
16+
17+
18+
class AppSettings(SettingsModel):
19+
application_name: str
20+
max_users: int
21+
feature_toggle: bool = False
22+
allowed_hosts: list[str] = Field(default_factory=list)
23+
regions: list[RegionSettings] = Field(default_factory=list)
24+
25+
26+
class RootSettings(SettingsModel):
27+
appsettings: AppSettings
28+
29+
30+
config = (
31+
ConfigurationBuilder(RootSettings)
32+
.add_in_memory_collection(
33+
{
34+
"appsettings": {
35+
"application_name": "AXA Portal",
36+
"max_users": 150,
37+
"feature_toggle": True,
38+
"allowed_hosts": ["api.local", "admin.local"],
39+
"regions": [
40+
{
41+
"name": "eu-west",
42+
"endpoints": [
43+
{"name": "catalog", "url": "https://eu/catalog"},
44+
{"name": "orders", "url": "https://eu/orders"},
45+
],
46+
},
47+
{
48+
"name": "us-east",
49+
"endpoints": [
50+
{"name": "catalog", "url": "https://us/catalog"},
51+
],
52+
},
53+
],
54+
}
55+
}
56+
)
57+
.build_configuration()
58+
)
59+
60+
app_name = config["appsettings:application_name"]
61+
max_users = config["appsettings:max_users"]
62+
first_region_name = config["appsettings:regions:0:name"]
63+
first_endpoint_url = config["appsettings:regions:0:endpoints:0:url"]
64+
65+
app_settings = config.get_section("appsettings").get(AppSettings)
66+
root_settings = config.bind()
67+
68+
print(f"Application Name: {app_name}")
69+
print(f"Max Users: {max_users}")
70+
print(f"First Region: {first_region_name}")
71+
print(f"First Endpoint URL: {first_endpoint_url}")
72+
print(f"Allowed Hosts: {app_settings.allowed_hosts}")
73+
print(f"Region count: {len(app_settings.regions)}")
74+
print(f"Root bound application name: {root_settings.appsettings.application_name}")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "axa-fr-app-settings"
7-
version = "0.2.0"
7+
version = "0.3.0"
88
description = "Typed application settings with a .NET-like configuration builder for Python."
99
readme = "README.md"
1010
license = { file = "LICENSE" }

src/axa_fr_app_settings/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .base import SettingsModel
44
from .builder import ConfigurationBuilder, SettingsBuilder
5+
from .configuration import ConfigurationRoot, ConfigurationSection
56
from .sources import (
67
CallableSource,
78
DictSource,
@@ -18,6 +19,8 @@
1819
__all__ = [
1920
"CallableSource",
2021
"ConfigurationBuilder",
22+
"ConfigurationRoot",
23+
"ConfigurationSection",
2124
"DictSource",
2225
"DotEnvFileSource",
2326
"EnvironmentVariablesSource",

src/axa_fr_app_settings/builder.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from pydantic import BaseModel
88

9+
from .configuration import ConfigurationRoot
910
from .merge import deep_merge
1011
from .sources import (
1112
CallableSource,
@@ -139,6 +140,9 @@ def build(self) -> TSettings:
139140
data = self.build_data()
140141
return self._settings_type.model_validate(data)
141142

143+
def build_configuration(self) -> ConfigurationRoot[TSettings]:
144+
return ConfigurationRoot(self.build_data(), self._settings_type)
145+
142146
def build_watched(
143147
self,
144148
*,

0 commit comments

Comments
 (0)