Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 2598ffd

Browse files
authored
Merge pull request #28 from TTWShell/fix/config-bool-conversion
Fix/config bool conversion
2 parents 25452a7 + 2155353 commit 2598ffd

19 files changed

Lines changed: 291 additions & 28 deletions

File tree

docs/api/config.md

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@ from pathlib import Path
5555

5656
from dependency_injector import containers, providers
5757
from zodiac_core.config import ConfigManagement
58+
from zodiac_core.utils import strtobool
5859

5960

6061
class Container(containers.DeclarativeContainer):
61-
config = providers.Configuration()
62+
config = providers.Configuration(strict=True)
6263

6364
@staticmethod
6465
def initialize():
@@ -70,14 +71,14 @@ class Container(containers.DeclarativeContainer):
7071

7172
container = Container()
7273
for path in config_files:
73-
container.config.from_ini(path)
74+
container.config.from_ini(path, required=True)
7475

7576
return container
7677

7778

7879
container = Container.initialize()
7980
db_url = container.config.db.url()
80-
db_echo = container.config.db.get("echo", as_=bool)
81+
db_echo = container.config.db.get("echo", as_=strtobool)
8182
```
8283

8384
### Testing Environment
@@ -140,7 +141,85 @@ print(config.db.port) # 5432 (default value applied)
140141

141142
---
142143

143-
## 4. API Reference
144+
## 4. Best Practices with dependency-injector
145+
146+
> For the full `providers.Configuration` API, see the [official documentation](https://python-dependency-injector.ets-labs.org/providers/configuration.html).
147+
148+
When using `providers.Configuration` from dependency-injector, follow these practices to catch configuration errors early and keep your code type-safe.
149+
150+
### Strict Mode
151+
152+
Always enable `strict=True` on the Configuration provider. Without it, accessing an undefined config key silently returns `None` instead of raising an error — bugs surface at runtime instead of startup.
153+
154+
```python
155+
# Good — typo or missing key raises immediately
156+
config = providers.Configuration(strict=True)
157+
158+
# Bad — config.db.hoost() silently returns None
159+
config = providers.Configuration()
160+
```
161+
162+
### Required Config Files
163+
164+
Pass `required=True` to `from_ini()` for files that must exist. By default, missing files are silently ignored.
165+
166+
```python
167+
for path in config_files:
168+
container.config.from_ini(path, required=True)
169+
```
170+
171+
### Type Conversion
172+
173+
All values from `.ini` files are strings. Use the built-in helpers or Pydantic models for conversion:
174+
175+
| Need | Approach |
176+
|------|----------|
177+
| Integer | `config.api.timeout.as_int()` |
178+
| Float | `config.api.ratio.as_float()` |
179+
| Bool | `config.db.echo.as_(strtobool)`**not** `as_(bool)`, since `bool("false")` is `True` |
180+
| Custom | `config.pi.as_(Decimal)` |
181+
| Whole section | `ConfigManagement.provide_config(container.config.db(), DbConfig)` — Pydantic handles all conversions |
182+
183+
The **Pydantic model approach** is recommended for sections with multiple fields — it handles type coercion, validation, and defaults in one place, and you don't need to worry about `strtobool` or `as_int()`.
184+
185+
Use `StrictConfig` as the base class instead of `BaseModel`. It adds `extra='forbid'` (rejects typo keys like `ech0`) and `frozen=True` (immutable after creation):
186+
187+
```python
188+
from zodiac_core.config import ConfigManagement, StrictConfig
189+
190+
class DbConfig(StrictConfig):
191+
url: str
192+
echo: bool = False # Pydantic correctly parses "false" → False
193+
194+
db_cfg = ConfigManagement.provide_config(container.config.db(), DbConfig)
195+
db.setup(database_url=db_cfg.url, echo=db_cfg.echo)
196+
```
197+
198+
### Environment Variable Interpolation
199+
200+
`.ini` files support `${ENV_VAR}` and `${ENV_VAR:default}` syntax for injecting secrets without hardcoding:
201+
202+
```ini
203+
[db]
204+
url = ${DATABASE_URL:sqlite+aiosqlite:///:memory:}
205+
echo = false
206+
```
207+
208+
To require that all referenced environment variables are defined (no silent empty substitution), pass `envs_required=True`:
209+
210+
```python
211+
container.config.from_ini(path, required=True, envs_required=True)
212+
```
213+
214+
---
215+
216+
## 5. API Reference
217+
218+
### StrictConfig
219+
::: zodiac_core.config.StrictConfig
220+
options:
221+
heading_level: 4
222+
show_root_heading: true
144223

145224
### Environment Enum
146225
::: zodiac_core.config.Environment

docs/api/context.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ from zodiac_core.http import init_http_client
4646

4747

4848
class Container(containers.DeclarativeContainer):
49-
config = providers.Configuration()
49+
config = providers.Configuration(strict=True)
5050

5151
external_http_client = providers.Resource(
5252
init_http_client,

docs/api/utils.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Utilities
2+
3+
Small helper functions that don't belong to any specific module.
4+
5+
## strtobool
6+
7+
Drop-in replacement for `distutils.util.strtobool`, which was removed in Python 3.13.
8+
9+
```python
10+
from zodiac_core.utils import strtobool
11+
12+
strtobool("true") # True
13+
strtobool("false") # False
14+
strtobool("yes") # True
15+
strtobool("no") # False
16+
```
17+
18+
::: zodiac_core.utils.strtobool
19+
options:
20+
heading_level: 3
21+
show_root_heading: true

docs/user-guide/architecture.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ from dependency_injector import containers, providers
251251
from zodiac_core.http import init_http_client
252252

253253
class Container(containers.DeclarativeContainer):
254-
config = providers.Configuration()
254+
config = providers.Configuration(strict=True)
255255
http_client = providers.Resource(
256256
init_http_client,
257257
base_url=config.github.base_url,
@@ -362,14 +362,16 @@ The generated template container loads configuration from `APPLICATION_ENVIRONME
362362
```python
363363
from pathlib import Path
364364
from zodiac_core.config import ConfigManagement
365+
from zodiac_core.utils import strtobool
365366

366367
config_dir = Path(__file__).resolve().parent.parent / "config"
367368
config_files = ConfigManagement.get_config_files(
368369
search_paths=[config_dir],
369370
env_var="APPLICATION_ENVIRONMENT",
370371
default_env="develop",
371372
)
372-
container.config.from_ini(*config_files)
373+
for path in config_files:
374+
container.config.from_ini(path, required=True)
373375
```
374376

375377
In tests, a common pattern is to set `APPLICATION_ENVIRONMENT=testing` and keep test-only values in `config/app.testing.ini`.
@@ -379,7 +381,7 @@ Access configuration in the container:
379381
```python
380382
# In main.py lifespan
381383
db_url = container.config.db.url()
382-
db_echo = container.config.db.echo.as_(bool)
384+
db_echo = container.config.db.echo.as_(strtobool)
383385
```
384386

385387
---

docs/user-guide/getting-started.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ The project uses `dependency-injector` to manage dependencies. The container is
6868
from dependency_injector import containers, providers
6969

7070
class Container(containers.DeclarativeContainer):
71-
config = providers.Configuration()
71+
config = providers.Configuration(strict=True)
7272

7373
# Infrastructure layer
7474
item_repository = providers.Factory(ItemRepository)
@@ -165,7 +165,8 @@ config_files = ConfigManagement.get_config_files(
165165
env_var="APPLICATION_ENVIRONMENT",
166166
default_env="develop",
167167
)
168-
container.config.from_ini(*config_files)
168+
for path in config_files:
169+
container.config.from_ini(path, required=True)
169170
```
170171

171172
For tests, it is recommended to add `config/app.testing.ini` and set:

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,4 +76,5 @@ nav:
7676
- Pagination: api/pagination.md
7777
- Routing & Response: api/routing.md
7878
- Data Schemas: api/schemas.md
79+
- Utilities: api/utils.md
7980
- Changelog: changelog.md

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ dev = [
114114
"asyncpg>=0.31.0",
115115
"aiosqlite>=0.22.1",
116116
"uvicorn>=0.40.0",
117+
"dependency-injector>=4.46.0",
117118
]
118119

119120
docs = [

tests/test_build.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ class TestPackageBuild:
99
"""Tests for verifying package build completeness."""
1010

1111
# Manually verified expected file counts
12-
EXPECTED_ZODIAC_FILES = 33 # Python files + template files (.jinja)
13-
EXPECTED_ZODIAC_CORE_FILES = 19 # Python files only
12+
EXPECTED_ZODIAC_FILES = 34 # Python files + template files (.jinja)
13+
EXPECTED_ZODIAC_CORE_FILES = 20 # Python files only
1414

1515
def test_build_includes_all_files(self, tmp_path):
1616
"""Verify that built package includes all required files from zodiac and zodiac_core."""

tests/test_config.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import os
22
from types import SimpleNamespace
33

4+
import pytest
5+
from dependency_injector import containers, errors, providers
46
from loguru import logger
5-
from pydantic import BaseModel
7+
from pydantic import BaseModel, ValidationError
68

7-
from zodiac_core import ConfigManagement, Environment
9+
from zodiac_core import ConfigManagement, Environment, StrictConfig
10+
from zodiac_core.utils import strtobool
811

912

1013
def test_get_config_files_base_only(tmp_path):
@@ -144,3 +147,74 @@ class AllDefaultConfig(BaseModel):
144147
config = ConfigManagement.provide_config({}, AllDefaultConfig)
145148
assert config.name == "app"
146149
assert config.version == "1.0.0"
150+
151+
152+
class TestConfigIntegration:
153+
"""Integration: dependency-injector Configuration with strict+required + strtobool."""
154+
155+
def _make_container(self, tmp_path, base_ini, override_ini=None):
156+
(tmp_path / "app.ini").write_text(base_ini)
157+
if override_ini:
158+
(tmp_path / "app.develop.ini").write_text(override_ini)
159+
160+
class C(containers.DeclarativeContainer):
161+
config = providers.Configuration(strict=True)
162+
163+
c = C()
164+
for path in ConfigManagement.get_config_files([tmp_path], default_env="develop"):
165+
c.config.from_ini(path, required=True)
166+
return c
167+
168+
@pytest.mark.parametrize("echo_val,expected", [("false", False), ("true", True)])
169+
def test_strtobool_as_callback(self, tmp_path, echo_val, expected):
170+
"""as_(strtobool) correctly converts 'false'/'true' from ini."""
171+
c = self._make_container(tmp_path, f"[db]\necho = {echo_val}\n")
172+
assert c.config.db.echo.as_(strtobool)() is expected
173+
174+
def test_strict_rejects_undefined_key(self, tmp_path):
175+
"""strict=True raises on accessing a key not in any loaded file."""
176+
c = self._make_container(tmp_path, "[db]\nurl = sqlite://\n")
177+
with pytest.raises(errors.Error, match="Undefined"):
178+
c.config.db.nonexistent()
179+
180+
def test_required_rejects_missing_file(self):
181+
"""from_ini(required=True) raises when the file does not exist."""
182+
183+
class C(containers.DeclarativeContainer):
184+
config = providers.Configuration(strict=True)
185+
186+
c = C()
187+
with pytest.raises(FileNotFoundError):
188+
c.config.from_ini("/tmp/nonexistent.ini", required=True)
189+
190+
def test_override_merges_with_strict(self, tmp_path):
191+
"""Base + override merge works under strict mode; override key wins."""
192+
c = self._make_container(
193+
tmp_path,
194+
"[db]\nurl = sqlite://\necho = false\n\n[cache]\nprefix = myapp\n",
195+
"[db]\necho = true\n",
196+
)
197+
assert c.config.db.url() == "sqlite://"
198+
assert c.config.db.echo.as_(strtobool)() is True
199+
assert c.config.cache.prefix() == "myapp"
200+
201+
202+
class TestStrictConfig:
203+
"""StrictConfig enforces extra='forbid' and frozen=True."""
204+
205+
def test_typo_field_rejected(self):
206+
class DbConfig(StrictConfig):
207+
url: str
208+
echo: bool = False
209+
210+
with pytest.raises(Exception, match="ech0"):
211+
DbConfig(url="sqlite://", ech0="true")
212+
213+
def test_immutable_after_creation(self):
214+
class DbConfig(StrictConfig):
215+
url: str
216+
echo: bool = False
217+
218+
cfg = DbConfig(url="sqlite://")
219+
with pytest.raises(ValidationError):
220+
cfg.echo = True

tests/test_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import pytest
2+
3+
from zodiac_core.utils import strtobool
4+
5+
6+
class TestStrtobool:
7+
@pytest.mark.parametrize("val", ["y", "yes", "t", "true", "on", "1"])
8+
def test_true_values(self, val):
9+
assert strtobool(val) is True
10+
11+
@pytest.mark.parametrize("val", ["n", "no", "f", "false", "off", "0"])
12+
def test_false_values(self, val):
13+
assert strtobool(val) is False
14+
15+
@pytest.mark.parametrize("val", ["", "maybe", "2", "truthy", "nope"])
16+
def test_invalid_raises_valueerror(self, val):
17+
with pytest.raises(ValueError, match="invalid truth value"):
18+
strtobool(val)

0 commit comments

Comments
 (0)