Skip to content

Commit dd45007

Browse files
committed
Add INI, XML, TOML and fix testing
1 parent 6dcf171 commit dd45007

10 files changed

Lines changed: 142 additions & 14 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,10 @@ Along with being fully configurable to support any arbitrary data source you'd
6262
like, the following types of data can immediately be used as configuration formats
6363
upon installation of prefer:
6464

65-
- JSON
65+
- JSON (with comments)
6666
- YAML
67+
- TOML
68+
- INI
6769

6870

6971
Why asyncronous?

prefer/configuration_spec.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,8 @@ def test_get_returns_none_for_unset_nested_identifier():
113113
)
114114
result = subject.get("level1.nonexistent")
115115
assert result is None
116+
117+
118+
def test_configuration_using_with_invalid_type_returns_empty():
119+
result = configuration.Configuration.using("invalid_string")
120+
assert result.context == {}

prefer/formatters/defaults.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
defaults = [
22
"prefer.formatters.json:JSONFormatter",
33
"prefer.formatters.yaml:YAMLFormatter",
4+
"prefer.formatters.ini:INIFormatter",
5+
"prefer.formatters.xml:XMLFormatter",
46
]

prefer/formatters/ini.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import configparser
2+
import io
3+
import typing
4+
5+
from prefer.formatters import formatter
6+
7+
8+
class INIFormatter(formatter.Formatter):
9+
@staticmethod
10+
def provides(identifier: str) -> bool:
11+
return identifier.endswith(".ini") or identifier.endswith(".cfg")
12+
13+
async def serialize(self, source: typing.Dict[str, typing.Any]) -> str:
14+
config = configparser.ConfigParser()
15+
for section, values in source.items():
16+
config[section] = values
17+
output = io.StringIO()
18+
config.write(output)
19+
return output.getvalue()
20+
21+
async def deserialize(self, source: str) -> typing.Dict[str, typing.Any]:
22+
config = configparser.ConfigParser()
23+
config.read_string(source)
24+
result: typing.Dict[str, typing.Any] = {
25+
section: dict(config[section]) for section in config.sections()
26+
}
27+
return result

prefer/formatters/ini_spec.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import pytest
2+
3+
from prefer.formatters import ini
4+
5+
INI_DATA = """[mock_config]
6+
name = Bailey
7+
8+
"""
9+
REAL_DATA = {"mock_config": {"name": "Bailey"}}
10+
11+
formatter = ini.INIFormatter()
12+
13+
14+
@pytest.mark.asyncio
15+
async def test_ini_formatter_provides_expected_file_extensions():
16+
assert ini.INIFormatter.provides("test.ini") is True
17+
assert ini.INIFormatter.provides("test.cfg") is True
18+
19+
20+
@pytest.mark.asyncio
21+
async def test_ini_formatter_does_not_provide_unexpected_file_extensions():
22+
assert ini.INIFormatter.provides("test.json") is False
23+
assert ini.INIFormatter.provides("test.yaml") is False
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_ini_formatter_serializes_to_ini():
28+
result = await formatter.serialize(REAL_DATA)
29+
assert "[mock_config]" in result
30+
assert "name = Bailey" in result
31+
32+
33+
@pytest.mark.asyncio
34+
async def test_ini_formatter_deserializes_from_ini():
35+
result = await formatter.deserialize(INI_DATA)
36+
assert result == REAL_DATA

prefer/formatters/json.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
import json
21
import typing
32

3+
import json5
4+
45
from prefer.formatters import formatter
56

67

78
class JSONFormatter(formatter.Formatter):
89
@staticmethod
910
def provides(identifier: str) -> bool:
10-
return identifier.endswith(".json")
11+
return identifier.endswith(".json") or identifier.endswith(".json5")
1112

1213
async def serialize(self, source: typing.Dict[str, typing.Any]) -> str:
13-
return json.dumps(source)
14+
result: str = json5.dumps(source, quote_keys=True)
15+
return result
1416

1517
async def deserialize(self, source: str) -> typing.Dict[str, typing.Any]:
16-
result: typing.Dict[str, typing.Any] = json.loads(source)
18+
result: typing.Dict[str, typing.Any] = json5.loads(source)
1719
return result

prefer/formatters/json_spec.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
@pytest.mark.asyncio
1414
async def test_json_native_formatter_provides_expected_file_extensions():
1515
assert json.JSONFormatter.provides("test.json") is True
16+
assert json.JSONFormatter.provides("test.json5") is True
1617

1718

1819
@pytest.mark.asyncio

prefer/formatters/xml.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import typing
2+
import xml.etree.ElementTree as ET
3+
4+
import xmljson
5+
6+
from prefer.formatters import formatter
7+
8+
9+
class XMLFormatter(formatter.Formatter):
10+
@staticmethod
11+
def provides(identifier: str) -> bool:
12+
return identifier.endswith(".xml")
13+
14+
async def serialize(self, source: typing.Dict[str, typing.Any]) -> str:
15+
elem = xmljson.badgerfish.etree(source)
16+
if isinstance(elem, list):
17+
elem = elem[0]
18+
return ET.tostring(elem, encoding="unicode")
19+
20+
async def deserialize(self, source: str) -> typing.Dict[str, typing.Any]:
21+
elem = ET.fromstring(source)
22+
result: typing.Dict[str, typing.Any] = xmljson.badgerfish.data(elem)
23+
return result

prefer/formatters/xml_spec.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import pytest
2+
3+
from prefer.formatters import xml
4+
5+
XML_DATA = "<root><name>Bailey</name></root>"
6+
REAL_DATA = {"root": {"name": {"$": "Bailey"}}}
7+
8+
formatter = xml.XMLFormatter()
9+
10+
11+
@pytest.mark.asyncio
12+
async def test_xml_formatter_provides_expected_file_extensions():
13+
assert xml.XMLFormatter.provides("test.xml") is True
14+
15+
16+
@pytest.mark.asyncio
17+
async def test_xml_formatter_does_not_provide_unexpected_file_extensions():
18+
assert xml.XMLFormatter.provides("test.json") is False
19+
assert xml.XMLFormatter.provides("test.yaml") is False
20+
21+
22+
@pytest.mark.asyncio
23+
async def test_xml_formatter_serializes_to_xml():
24+
result = await formatter.serialize(REAL_DATA)
25+
assert "<root>" in result
26+
assert "<name>Bailey</name>" in result
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_xml_formatter_deserializes_from_xml():
31+
result = await formatter.deserialize(XML_DATA)
32+
assert "root" in result
33+
assert "name" in result["root"]

pyproject.toml

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ classifiers = [
2626
dependencies = [
2727
"PyYAML>=5.1",
2828
"xmljson>=0.2.0",
29+
"json5>=0.9.0",
2930
]
3031

3132
[project.optional-dependencies]
@@ -67,6 +68,10 @@ exclude = [
6768
".*_spec\\.py$",
6869
]
6970

71+
[[tool.mypy.overrides]]
72+
module = "xmljson"
73+
ignore_missing_imports = true
74+
7075
[tool.ruff]
7176
line-length = 79
7277
target-version = "py38"
@@ -84,15 +89,7 @@ source = ["prefer"]
8489
omit = ["*/tests/*", "*/__pycache__/*", "*_spec.py"]
8590

8691
[tool.coverage.report]
87-
exclude_lines = [
88-
"pragma: no cover",
89-
"def __repr__",
90-
"raise AssertionError",
91-
"raise NotImplementedError",
92-
"if __name__ == .__main__.:",
93-
"if typing.TYPE_CHECKING:",
94-
]
95-
fail_under = 99
92+
fail_under = 100
9693
show_missing = true
9794

9895
[tool.coverage.html]

0 commit comments

Comments
 (0)