Skip to content

Commit 05bae70

Browse files
committed
Added validation using jsonschema
1 parent bf60e2b commit 05bae70

File tree

5 files changed

+175
-1
lines changed

5 files changed

+175
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ All notable changes to this project will be documented in this file.
77
### Added
88

99
- Allow to pass a `ignore_missing_paths` parameter to each config method
10+
- Support for Hashicorp Vault credentials (in `config.contrib`)
11+
12+
### Changed
13+
14+
- Added a `validate` method to validate `Configuration` instances against a [json schema](https://json-schema.org/understanding-json-schema/basics.html#basics).
1015

1116
## [0.9.0] - 2023-08-04
1217

README.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,13 +276,62 @@ When setting the `interpolate` parameter in any `Configuration` instance, the li
276276
cfg = config_from_dict({
277277
"percentage": "{val:.3%}",
278278
"with_sign": "{val:+f}",
279-
"val": 1.23456}, interpolate=True)
279+
"val": 1.23456,
280+
}, interpolate=True)
280281

281282
assert cfg.val == 1.23456
282283
assert cfg.with_sign == "+1.234560"
283284
assert cfg.percentage == "123.456%"
284285
```
285286

287+
###### Validation
288+
289+
Validation relies on the [jsonchema](https://github.com/python-jsonschema/jsonschema) library, which is automatically installed using the extra `validation`. To use it, call the `validate` method on any `Configuration` instance in a manner similar to what is described on the `jsonschema` library:
290+
291+
```python
292+
schema = {
293+
"type" : "object",
294+
"properties" : {
295+
"price" : {"type" : "number"},
296+
"name" : {"type" : "string"},
297+
},
298+
}
299+
300+
cfg = config_from_dict({"name" : "Eggs", "price" : 34.99})
301+
assert cfg.validate(schema)
302+
303+
cfg = config_from_dict({"name" : "Eggs", "price" : "Invalid"})
304+
assert not cfg.validate(schema)
305+
306+
# pass the `raise_on_error` parameter to get the traceback of validation failures
307+
cfg.validate(schema, raise_on_error=True)
308+
# ValidationError: 'Invalid' is not of type 'number'
309+
```
310+
311+
To use the [format](https://python-jsonschema.readthedocs.io/en/latest/validate/#validating-formats) feature of the `jsonschema` library, the extra dependencies must be installed separately as explained in the documentation of `jsonschema`.
312+
313+
```python
314+
from jsonschema import Draft202012Validator
315+
316+
schema = {
317+
"type" : "object",
318+
"properties" : {
319+
"ip" : {"format" : "ipv4"},
320+
},
321+
}
322+
323+
cfg = config_from_dict({"ip": "10.0.0.1"})
324+
assert cfg.validate(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)
325+
326+
cfg = config_from_dict({"ip": "10"})
327+
assert not cfg.validate(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)
328+
329+
# with the `raise_on_error` parameter:
330+
c.validate(schema, raise_on_error=True, format_checker=Draft202012Validator.FORMAT_CHECKER)
331+
# ValidationError: '10' is not a 'ipv4'
332+
```
333+
334+
286335
## Extras
287336

288337
The `config.contrib` package contains extra implementations of the `Configuration` class used for special cases. Currently the following are implemented:
@@ -311,6 +360,14 @@ The `config.contrib` package contains extra implementations of the `Configuratio
311360
pip install python-configuration[gcp]
312361
```
313362

363+
* `HashicorpVaultConfiguration` in `config.contrib.vault`, which takes Hashicorp Vault
364+
credentials into a `Configuration`-compatible instance. To install the needed dependencies
365+
execute
366+
367+
```shell
368+
pip install python-configuration[vault]
369+
```
370+
314371
## Features
315372

316373
* Load multiple configuration types

config/configuration.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,21 @@ def reload(self) -> None: # pragma: no cover
391391
"""
392392
raise NotImplementedError()
393393

394+
def validate(
395+
self, schema: Any, raise_on_error: bool = False, **kwargs: Mapping[str, Any]
396+
) -> bool:
397+
try:
398+
from jsonschema import validate, ValidationError
399+
except ImportError: # pragma: no cover
400+
raise RuntimeError("Validation requires the `jsonschema` library.")
401+
try:
402+
validate(self.as_dict(), schema, **kwargs)
403+
except ValidationError as err:
404+
if raise_on_error:
405+
raise err
406+
return False
407+
return True
408+
394409
@contextmanager
395410
def dotted_iter(self) -> Iterator["Configuration"]:
396411
"""

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ google-cloud-secret-manager = { version = "^2.16.3", optional = true }
2020
hvac = { version ="^1.1.1", optional = true }
2121
pyyaml = { version = "^6.0", optional = true }
2222
toml = { version = "^0.10.0", optional = true }
23+
jsonschema = { version = "^4.18.6", optional = true }
2324

2425
[tool.poetry.group.dev.dependencies]
2526
flake8-blind-except = "^0.2.0"
@@ -47,6 +48,7 @@ gcp = ["google-cloud-secret-manager"]
4748
toml = ["toml"]
4849
vault = ["hvac"]
4950
yaml = ["pyyaml"]
51+
validation = ["jsonschema"]
5052

5153
[tool.black]
5254
line-length = 88
@@ -89,6 +91,8 @@ module= [
8991
'botocore.exceptions',
9092
'hvac',
9193
'hvac.exceptions',
94+
'jsonschema',
95+
'jsonschema.exceptions'
9296
]
9397
ignore_missing_imports = true
9498

tests/test_validation.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
from config import (
2+
Configuration,
3+
ConfigurationSet,
4+
EnvConfiguration,
5+
config,
6+
config_from_dict,
7+
)
8+
import pytest
9+
10+
try:
11+
import jsonschema
12+
except ImportError:
13+
jsonschema = None
14+
15+
16+
@pytest.mark.skipif("jsonschema is None")
17+
def test_validation_ok(): # type: ignore
18+
d = {"items": [1, 3]}
19+
cfg = config_from_dict(d)
20+
21+
schema = {
22+
"type": "object",
23+
"properties": {
24+
"items": {
25+
"type": "array",
26+
"items": {"enum": [1, 2, 3]},
27+
"maxItems": 2,
28+
}
29+
},
30+
}
31+
32+
assert cfg.validate(schema)
33+
34+
35+
@pytest.mark.skipif("jsonschema is None")
36+
def test_validation_fail(): # type: ignore
37+
from jsonschema.exceptions import ValidationError
38+
39+
schema = {
40+
"type": "object",
41+
"properties": {
42+
"items": {
43+
"type": "array",
44+
"items": {"enum": [1, 2, 3]},
45+
"maxItems": 2,
46+
}
47+
},
48+
}
49+
50+
with pytest.raises(ValidationError) as err:
51+
d = {"items": [1, 4]}
52+
cfg = config_from_dict(d)
53+
assert not cfg.validate(schema)
54+
cfg.validate(schema, raise_on_error=True)
55+
assert "4 is not one of [1, 2, 3]" in str(err)
56+
57+
with pytest.raises(ValidationError) as err:
58+
d = {"items": [1, 2, 3]}
59+
cfg = config_from_dict(d)
60+
assert not cfg.validate(schema)
61+
cfg.validate(schema, raise_on_error=True)
62+
assert "[1, 2, 3] is too long" in str(err)
63+
64+
65+
@pytest.mark.skipif("jsonschema is None")
66+
def test_validation_format(): # type: ignore
67+
from jsonschema import Draft202012Validator
68+
from jsonschema.exceptions import ValidationError
69+
70+
schema = {
71+
"type": "object",
72+
"properties": {
73+
"ip": {"format": "ipv4"},
74+
},
75+
}
76+
77+
cfg = config_from_dict({"ip": "10.0.0.1"})
78+
assert cfg.validate(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)
79+
80+
# this passes since we didn't specify the format checker
81+
cfg = config_from_dict({"ip": "10"})
82+
assert cfg.validate(schema)
83+
84+
# fails with the format checker
85+
with pytest.raises(ValidationError) as err:
86+
cfg = config_from_dict({"ip": "10"})
87+
cfg.validate(
88+
schema,
89+
raise_on_error=True,
90+
format_checker=Draft202012Validator.FORMAT_CHECKER,
91+
)
92+
print(str(err))
93+
assert "'10' is not a 'ipv4'" in str(err)

0 commit comments

Comments
 (0)