Skip to content

Commit 11f184e

Browse files
authored
Add function that pretty prints a dictionary (#13)
* Add function that pretty prints a dictionary * Test with rich
1 parent a17dea7 commit 11f184e

File tree

8 files changed

+163
-59
lines changed

8 files changed

+163
-59
lines changed

examples/validate/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from pydantic import BaseModel, ConfigDict, HttpUrl, SecretStr
1111

12-
from configaroo import Configuration
12+
from configaroo import Configuration, print_configuration
1313

1414

1515
class ExactBaseModel(BaseModel):
@@ -42,7 +42,7 @@ class ConfigModel(ExactBaseModel):
4242
server: ServerConfig
4343

4444

45-
def get_configuration():
45+
def get_configuration() -> ConfigModel:
4646
"""Read and validate the configuration."""
4747
return Configuration.from_file(
4848
Path(__file__).parent / "settings.toml",
@@ -51,4 +51,4 @@ def get_configuration():
5151

5252

5353
if __name__ == "__main__":
54-
print(get_configuration())
54+
print_configuration(get_configuration())

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dev = [
4242
"mypy>=1.17.1",
4343
"pre-commit>=4.2.0",
4444
"pytest>=8.3.5",
45+
"rich>=14.1.0",
4546
"ruff>=0.11.11",
4647
"tomli-w>=1.2.0",
4748
]

src/configaroo/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
"""Bouncy configuration handling"""
22

3-
from configaroo.configuration import Configuration
3+
from configaroo.configuration import Configuration, print_configuration
44
from configaroo.exceptions import (
55
ConfigarooException,
66
MissingEnvironmentVariableError,
77
UnsupportedLoaderError,
88
)
99

1010
__all__ = [
11-
"Configuration",
1211
"ConfigarooException",
12+
"Configuration",
1313
"MissingEnvironmentVariableError",
14+
"print_configuration",
1415
"UnsupportedLoaderError",
1516
]
1617

src/configaroo/configuration.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import re
66
from collections import UserDict
77
from pathlib import Path
8-
from typing import Any, Self, Type, TypeVar
8+
from typing import Any, Callable, Self, Type, TypeVar
99

1010
from pydantic import BaseModel
1111

@@ -15,7 +15,7 @@
1515
ModelT = TypeVar("ModelT", bound=BaseModel)
1616

1717

18-
class Configuration(UserDict):
18+
class Configuration(UserDict[str, Any]):
1919
"""A Configuration is a dict-like structure with some conveniences"""
2020

2121
@classmethod
@@ -183,6 +183,50 @@ def to_flat_dict(self, _prefix: str = "") -> dict[str, Any]:
183183
}
184184

185185

186+
def print_configuration(config: Configuration | BaseModel, indent: int = 4) -> None:
187+
"""Pretty print a configuration.
188+
189+
If rich is installed, then a rich console is used for the printing.
190+
"""
191+
return _print_dict_as_tree(
192+
config.model_dump() if isinstance(config, BaseModel) else config,
193+
indent=indent,
194+
print=_get_rich_print(),
195+
)
196+
197+
198+
def _get_rich_print() -> Callable[[str], None]:
199+
"""Initialize a Rich console if Rich is installed, otherwise use built-in print."""
200+
try:
201+
from rich.console import Console
202+
203+
return Console().print
204+
except ImportError:
205+
import builtins
206+
207+
return builtins.print
208+
209+
210+
def _print_dict_as_tree(
211+
data: dict[str, Any] | UserDict[str, Any] | Configuration,
212+
indent: int = 4,
213+
current_indent: int = 0,
214+
print: Callable[[str], None] = print,
215+
) -> None:
216+
"""Print a nested dictionary as a tree."""
217+
for key, value in data.items():
218+
if isinstance(value, dict | UserDict | Configuration):
219+
print(" " * current_indent + f"- {key}")
220+
_print_dict_as_tree(
221+
value,
222+
indent=indent,
223+
current_indent=current_indent + indent,
224+
print=print,
225+
)
226+
else:
227+
print(" " * current_indent + f"- {key}: {value!r}")
228+
229+
186230
def _find_pyproject_toml(
187231
path: Path | None = None, _file_name: str = "pyproject.toml"
188232
) -> Path:

tests/conftest.py

Lines changed: 9 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,60 +3,16 @@
33
import json
44
from pathlib import Path
55
from types import ModuleType
6-
from typing import Type
76

8-
import pydantic
97
import pytest
108
import tomli_w
119

1210
from configaroo import Configuration
13-
14-
15-
# Configuration schema
16-
class StrictSchema(pydantic.BaseModel):
17-
model_config = pydantic.ConfigDict(extra="forbid")
18-
19-
20-
class DeeplyNestedSchema(StrictSchema):
21-
sea: str
22-
23-
24-
class NestedSchema(StrictSchema):
25-
pie: float
26-
seven: int
27-
deep: DeeplyNestedSchema
28-
29-
30-
class LogSchema(StrictSchema):
31-
level: str
32-
format: str
33-
34-
35-
class WithDotSchema(StrictSchema):
36-
org_num: int = pydantic.Field(alias="org.num")
37-
38-
39-
class PathsSchema(StrictSchema):
40-
relative: Path
41-
dynamic: Path
42-
absolute: Path
43-
directory: Path
44-
nested: Path
45-
46-
47-
class ConfigSchema(StrictSchema):
48-
number: int
49-
word: str
50-
phrase: str
51-
things: list[str]
52-
nested: NestedSchema
53-
log: LogSchema
54-
with_dot: WithDotSchema
55-
paths: PathsSchema
11+
from tests.schema import ConfigSchema
5612

5713

5814
@pytest.fixture
59-
def model() -> Type[ConfigSchema]:
15+
def model() -> type[ConfigSchema]:
6016
"""A schema for the test configuration"""
6117
return ConfigSchema
6218

@@ -89,30 +45,32 @@ def base_path() -> Path:
8945

9046

9147
@pytest.fixture
92-
def toml_path(base_path, config) -> Path:
48+
def toml_path(base_path: Path, config: Configuration) -> Path:
9349
"""A path to a TOML file representing the configuration"""
9450
return write_file(base_path / "files" / "config.toml", tomli_w, config)
9551

9652

9753
@pytest.fixture
98-
def other_toml_path(base_path, config) -> Path:
54+
def other_toml_path(base_path: Path, config: Configuration) -> Path:
9955
"""An alternative path to a TOML file representing the configuration"""
10056
return write_file(base_path / "files" / "tomlfile", tomli_w, config)
10157

10258

10359
@pytest.fixture
104-
def json_path(base_path, config) -> Path:
60+
def json_path(base_path: Path, config: Configuration) -> Path:
10561
"""A path to a JSON file representing the configuration"""
10662
return write_file(base_path / "files" / "config.json", json, config, indent=4)
10763

10864

10965
@pytest.fixture
110-
def other_json_path(base_path, config) -> Path:
66+
def other_json_path(base_path: Path, config: Configuration) -> Path:
11167
"""A path to a JSON file representing the configuration"""
11268
return write_file(base_path / "files" / "jsonfile", json, config, indent=4)
11369

11470

115-
def write_file(path: Path, lib: ModuleType, config: Configuration, **kwargs) -> Path:
71+
def write_file(
72+
path: Path, lib: ModuleType, config: Configuration, **kwargs: str | int
73+
) -> Path:
11674
"""Write a configuration to file. Return path for convenience"""
11775
path.write_text(lib.dumps(config.to_dict(), **kwargs), encoding="utf-8")
11876
return path

tests/schema.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""Configuration schema used in tests."""
2+
3+
from pathlib import Path
4+
5+
import pydantic
6+
7+
8+
# Configuration schema
9+
class StrictSchema(pydantic.BaseModel):
10+
model_config = pydantic.ConfigDict(extra="forbid")
11+
12+
13+
class DeeplyNestedSchema(StrictSchema):
14+
sea: str
15+
16+
17+
class NestedSchema(StrictSchema):
18+
pie: float
19+
seven: int
20+
deep: DeeplyNestedSchema
21+
22+
23+
class LogSchema(StrictSchema):
24+
level: str
25+
format: str
26+
27+
28+
class WithDotSchema(StrictSchema):
29+
org_num: int = pydantic.Field(alias="org.num")
30+
31+
32+
class PathsSchema(StrictSchema):
33+
relative: Path
34+
dynamic: Path
35+
absolute: Path
36+
directory: Path
37+
nested: Path
38+
39+
40+
class ConfigSchema(StrictSchema):
41+
number: int
42+
word: str
43+
phrase: str
44+
things: list[str]
45+
nested: NestedSchema
46+
log: LogSchema
47+
with_dot: WithDotSchema
48+
paths: PathsSchema

tests/test_configuration.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,4 +142,6 @@ def test_incomplete_formatter():
142142
def test_public_classes_are_exposed():
143143
"""Test that the __all__ attribute exposes all public classes"""
144144
public_classes = [attr for attr in dir(configaroo) if "A" <= attr[:1] <= "Z"]
145-
assert sorted(public_classes) == sorted(configaroo.__all__)
145+
assert sorted(public_classes) == sorted(
146+
cls for cls in configaroo.__all__ if "A" <= cls[:1] <= "Z"
147+
)

tests/test_print.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Test pretty printing of configurations."""
2+
3+
from configaroo import Configuration, print_configuration
4+
from tests.schema import ConfigSchema
5+
6+
7+
def test_printing_of_config(capsys, config: Configuration) -> None:
8+
"""Test that a configuration can be printed."""
9+
print_configuration(config, indent=4)
10+
stdout = capsys.readouterr().out
11+
lines = stdout.splitlines()
12+
13+
assert "- number: 42" in lines
14+
assert "- word: 'platypus'" in lines
15+
assert "- nested" in lines
16+
assert " - pie: 3.14" in lines
17+
18+
19+
def test_indentation(capsys, config: Configuration) -> None:
20+
"""Test that indentation can be controlled."""
21+
print_configuration(config, indent=7)
22+
stdout = capsys.readouterr().out
23+
lines = stdout.splitlines()
24+
25+
assert " - pie: 3.14" in lines
26+
27+
28+
def test_printing_of_basemodel(
29+
capsys, config: Configuration, model: type[ConfigSchema]
30+
) -> None:
31+
"""Test that a configuration converted into a BaseModel can be printed."""
32+
print_configuration(config.with_model(model))
33+
stdout = capsys.readouterr().out
34+
lines = stdout.splitlines()
35+
36+
assert "- number: 42" in lines
37+
assert "- word: 'platypus'" in lines
38+
assert "- nested" in lines
39+
assert " - pie: 3.14" in lines
40+
41+
42+
def test_printing_of_dynamic_values(capsys, config: Configuration) -> None:
43+
"""Test that interpolated values are printed correctly."""
44+
print_configuration(config.parse_dynamic({"message": "testing configaroo"}))
45+
stdout = capsys.readouterr().out
46+
lines = stdout.splitlines()
47+
48+
assert "- number: 42" in lines
49+
assert "- phrase: 'The meaning of life is 42'" in lines
50+
assert " - format: '<level>{level:<8} testing configaroo</level>'" in lines

0 commit comments

Comments
 (0)