Skip to content

Commit 5ee55a6

Browse files
authored
Validation (#86)
* Added validation using jsonschema * Update version * Default to using the poetry --all-extras flag
1 parent bf60e2b commit 5ee55a6

File tree

6 files changed

+194
-5
lines changed

6 files changed

+194
-5
lines changed

.github/workflows/run_tests.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ jobs:
4949

5050
- name: Install dependencies
5151
if: steps.cache.outputs.cache-hit != 'true'
52-
run: poetry install -v -E toml -E yaml -E azure -E aws -E gcp -E vault
52+
run: poetry install -v --all-extras
5353

5454
- name: Run pytest
5555
run: poetry run pytest --cov=./ --cov-report=xml

CHANGELOG.md

+20-1
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
8+
## [0.9.1] - 2023-08-06
9+
710
### Added
811

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

1117
## [0.9.0] - 2023-08-04
1218

@@ -31,12 +37,14 @@ All notable changes to this project will be documented in this file.
3137

3238
- Configurations from ini file won't be converted to lower case if `lowercase_keys = False`
3339

40+
3441
## [0.8.2] - 2021-01-30
3542

3643
### Fixed
3744

3845
- The behavior of merging sets was incorrect since version 0.8.0
3946

47+
4048
## [0.8.0] - 2020-08-01
4149

4250
### Changed
@@ -60,24 +68,28 @@ with cfg.dotted_iter():
6068
- Support for _.env_-type files
6169
- Option for deep interpolation. To activate that mode, use one of the enum values in `InterpolateEnumType` as the `interpolate_type` parameter. This allows for hierachical _templates_, in which configuration objects use the values from lower ones to interpolate instead of simply overriding.
6270

71+
6372
## [0.7.1] - 2020-07-05
6473

6574
### Fixed
6675

6776
- Installation with `poetry` because of changes to pytest-black
6877

78+
6979
## [0.7.0] - 2020-05-06
7080

7181
### Added
7282

7383
- New string interpolation feature
7484

85+
7586
## [0.6.1] - 2020-04-24
7687

7788
### Changed
7889

7990
- Added a `separator` argument to `config` function
8091

92+
8193
## [0.6.0] - 2020-01-22
8294

8395
### Added
@@ -86,6 +98,7 @@ with cfg.dotted_iter():
8698
- Added a `reload` method to refresh a `Configuration` instance (can be used to reload a configuration from a file that may have changed).
8799
- Added a `configs` method to expose the underlying instances of a `ConfigurationSet`
88100

101+
89102
## [0.5.0] - 2020-01-08
90103

91104
### Added
@@ -98,13 +111,15 @@ with cfg.dotted_iter():
98111

99112
- Changed the `__repr__` and `__str__` methods so possibly sensitive values are not printed by default.
100113

114+
101115
## [0.4.0] - 2019-10-11
102116

103117
### Added
104118

105119
- Allow path-based failures using the `config` function.
106120
- Added a levels option to the dict-like objects.
107121

122+
108123
## [0.3.1] - 2019-08-20
109124

110125
### Added
@@ -113,26 +128,30 @@ with cfg.dotted_iter():
113128
- TravisCI support
114129
- Codecov
115130

131+
116132
## [0.3.0] - 2019-08-16
117133

118134
### Changed
119135

120136
- Changed the old behavior in which every key was converted to lower case.
121137

138+
122139
## [0.2.0] - 2019-07-16
123140

124141
### Added
125142

126143
- Added Sphinx documentation
127144
- Added a `remove_levels` parameter to the config function
128145

146+
129147
## [0.1.0] - 2019-01-16
130148

131149
### Added
132150

133151
- Initial version
134152

135-
[unreleased]: https://github.com/tr11/python-configuration/compare/0.9.0...HEAD
153+
[unreleased]: https://github.com/tr11/python-configuration/compare/0.9.1...HEAD
154+
[0.9.1]: https://github.com/tr11/python-configuration/compare/0.9.0...0.9.1
136155
[0.9.0]: https://github.com/tr11/python-configuration/compare/0.8.3...0.9.0
137156
[0.8.3]: https://github.com/tr11/python-configuration/compare/0.8.2...0.8.3
138157
[0.8.2]: https://github.com/tr11/python-configuration/compare/0.8.0...0.8.2

README.md

+59-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ and optionally
2626
* Azure Key Vault credentials
2727
* AWS Secrets Manager credentials
2828
* GCP Secret Manager credentials
29+
* Hashicorp Vault credentials
2930

3031
## Installing
3132

@@ -276,13 +277,62 @@ When setting the `interpolate` parameter in any `Configuration` instance, the li
276277
cfg = config_from_dict({
277278
"percentage": "{val:.3%}",
278279
"with_sign": "{val:+f}",
279-
"val": 1.23456}, interpolate=True)
280+
"val": 1.23456,
281+
}, interpolate=True)
280282

281283
assert cfg.val == 1.23456
282284
assert cfg.with_sign == "+1.234560"
283285
assert cfg.percentage == "123.456%"
284286
```
285287

288+
###### Validation
289+
290+
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:
291+
292+
```python
293+
schema = {
294+
"type" : "object",
295+
"properties" : {
296+
"price" : {"type" : "number"},
297+
"name" : {"type" : "string"},
298+
},
299+
}
300+
301+
cfg = config_from_dict({"name" : "Eggs", "price" : 34.99})
302+
assert cfg.validate(schema)
303+
304+
cfg = config_from_dict({"name" : "Eggs", "price" : "Invalid"})
305+
assert not cfg.validate(schema)
306+
307+
# pass the `raise_on_error` parameter to get the traceback of validation failures
308+
cfg.validate(schema, raise_on_error=True)
309+
# ValidationError: 'Invalid' is not of type 'number'
310+
```
311+
312+
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`.
313+
314+
```python
315+
from jsonschema import Draft202012Validator
316+
317+
schema = {
318+
"type" : "object",
319+
"properties" : {
320+
"ip" : {"format" : "ipv4"},
321+
},
322+
}
323+
324+
cfg = config_from_dict({"ip": "10.0.0.1"})
325+
assert cfg.validate(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)
326+
327+
cfg = config_from_dict({"ip": "10"})
328+
assert not cfg.validate(schema, format_checker=Draft202012Validator.FORMAT_CHECKER)
329+
330+
# with the `raise_on_error` parameter:
331+
c.validate(schema, raise_on_error=True, format_checker=Draft202012Validator.FORMAT_CHECKER)
332+
# ValidationError: '10' is not a 'ipv4'
333+
```
334+
335+
286336
## Extras
287337

288338
The `config.contrib` package contains extra implementations of the `Configuration` class used for special cases. Currently the following are implemented:
@@ -311,6 +361,14 @@ The `config.contrib` package contains extra implementations of the `Configuratio
311361
pip install python-configuration[gcp]
312362
```
313363

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

316374
* Load multiple configuration types

config/configuration.py

+15
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

+6-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ name = "python-configuration"
99
packages = [{ include = "config" }]
1010
readme = 'README.md'
1111
repository = "https://github.com/tr11/python-configuration"
12-
version = "0.9.0"
12+
version = "0.9.1"
1313

1414
[tool.poetry.dependencies]
1515
python = "^3.8.1"
@@ -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
@@ -60,7 +62,7 @@ envlist = py38, py39, py310, py311
6062
[testenv]
6163
allowlist_externals = poetry
6264
commands =
63-
poetry install -v -E toml -E yaml -E azure -E aws -E gcp -E vault
65+
poetry install -v --all-extras
6466
poetry run pytest
6567
"""
6668

@@ -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

+93
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)