toml-combine 0.4.0__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {toml_combine-0.4.0 → toml_combine-0.6.0}/.pre-commit-config.yaml +2 -2
- {toml_combine-0.4.0 → toml_combine-0.6.0}/PKG-INFO +37 -20
- {toml_combine-0.4.0 → toml_combine-0.6.0}/README.md +36 -19
- {toml_combine-0.4.0 → toml_combine-0.6.0}/tests/test.toml +4 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/tests/test_cli.py +2 -1
- {toml_combine-0.4.0 → toml_combine-0.6.0}/tests/test_combiner.py +160 -51
- {toml_combine-0.4.0 → toml_combine-0.6.0}/tests/test_lib.py +52 -23
- {toml_combine-0.4.0 → toml_combine-0.6.0}/toml_combine/combiner.py +61 -57
- {toml_combine-0.4.0 → toml_combine-0.6.0}/toml_combine/exceptions.py +1 -1
- {toml_combine-0.4.0 → toml_combine-0.6.0}/toml_combine/lib.py +1 -2
- {toml_combine-0.4.0 → toml_combine-0.6.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/.github/renovate.json5 +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/.github/workflows/ci.yml +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/pyproject.toml +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/tests/result.json +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/toml_combine/__init__.py +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/toml_combine/__main__.py +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/toml_combine/cli.py +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/toml_combine/toml.py +0 -0
- {toml_combine-0.4.0 → toml_combine-0.6.0}/uv.lock +0 -0
@@ -22,11 +22,11 @@ repos:
|
|
22
22
|
- id: mixed-line-ending
|
23
23
|
- repo: https://github.com/astral-sh/uv-pre-commit
|
24
24
|
# uv version.
|
25
|
-
rev: 0.6.
|
25
|
+
rev: 0.6.14
|
26
26
|
hooks:
|
27
27
|
- id: uv-lock
|
28
28
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
29
|
-
rev: v0.11.
|
29
|
+
rev: v0.11.5
|
30
30
|
hooks:
|
31
31
|
- id: ruff
|
32
32
|
args: [--fix, --unsafe-fixes, --show-fixes]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: toml-combine
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.6.0
|
4
4
|
Summary: A tool for combining complex configurations in TOML format.
|
5
5
|
Author-email: Joachim Jablon <ewjoachim@gmail.com>
|
6
6
|
License-Expression: MIT
|
@@ -29,7 +29,7 @@ parts that are common to everyone.
|
|
29
29
|
|
30
30
|
### The config file
|
31
31
|
|
32
|
-
The configuration file is usually a TOML file. Here's a small example:
|
32
|
+
The configuration file is (usually) a TOML file. Here's a small example:
|
33
33
|
|
34
34
|
```toml
|
35
35
|
[dimensions]
|
@@ -62,14 +62,19 @@ The common configuration to start from, before we start overlaying overrides on
|
|
62
62
|
### Overrides
|
63
63
|
|
64
64
|
Overrides define a set of condition where they apply (`when.<dimension> =
|
65
|
-
"<value>"`) and the values that are
|
66
|
-
specific to more specific, each one overriding the values of the previous ones:
|
65
|
+
"<value>"`) and the values that are overridden when they're applicable.
|
67
66
|
|
68
|
-
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
67
|
+
- In case 2 overrides are applicable and define a value for the same key, if one is more
|
68
|
+
specific than the other (e.g. env=prod,region=us is more specific than env=prod) then
|
69
|
+
its values will have precedence.
|
70
|
+
- If they are mutually exclusive, (env=prod vs env=staging) precedence is irrelevant.
|
71
|
+
- If you try to generate the configuration and 2 applicable overrides define a value for
|
72
|
+
the same key, an error will be raised (e.g. env=staging and region=eu). In that case,
|
73
|
+
you should add dimensions to either override to make them mutually exclusive or make
|
74
|
+
one more specific than the other.
|
75
|
+
|
76
|
+
Note that it's not a problem if incompatible overrides exist in your configuration, as
|
77
|
+
long as they are not both applicable in the same call.
|
73
78
|
|
74
79
|
> [!Note]
|
75
80
|
> Defining a list as the value of one or more conditions in an override
|
@@ -77,14 +82,11 @@ specific to more specific, each one overriding the values of the previous ones:
|
|
77
82
|
|
78
83
|
### The configuration itself
|
79
84
|
|
80
|
-
Under the layer of `dimensions/default/override/mapping` system, what you actually
|
81
|
-
in the configuration is completely up to you. That said, only nested
|
85
|
+
Under the layer of `dimensions/default/override/mapping` system, what you actually
|
86
|
+
define in the configuration is completely up to you. That said, only nested
|
82
87
|
"dictionnaries"/"objects"/"tables"/"mapping" (those are all the same things in
|
83
|
-
Python/JS/Toml lingo) will be merged between the default and the overrides,
|
84
|
-
arrays will just replace one another. See `Arrays` below.
|
85
|
-
|
86
|
-
In the generated configuration, the dimensions of the output will appear in the generated
|
87
|
-
object as an object under the `dimensions` key.
|
88
|
+
Python/JS/Toml lingo) will be merged between the default and the applicable overrides,
|
89
|
+
while arrays will just replace one another. See `Arrays` below.
|
88
90
|
|
89
91
|
### Arrays
|
90
92
|
|
@@ -141,9 +143,9 @@ Example with the config from the previous section:
|
|
141
143
|
|
142
144
|
```console
|
143
145
|
$ toml-combine path/to/config.toml --environment=staging
|
144
|
-
|
145
|
-
environment = "staging"
|
146
|
+
```
|
146
147
|
|
148
|
+
```toml
|
147
149
|
[fruits]
|
148
150
|
apple.color = "red"
|
149
151
|
orange.color = "orange"
|
@@ -188,8 +190,9 @@ container.image_name = "my-image-backend"
|
|
188
190
|
container.port = 8080
|
189
191
|
|
190
192
|
[[override]]
|
191
|
-
|
193
|
+
when.service = "backend"
|
192
194
|
when.environment = "dev"
|
195
|
+
name = "service-dev"
|
193
196
|
container.env.DEBUG = true
|
194
197
|
|
195
198
|
[[override]]
|
@@ -202,6 +205,9 @@ This produces the following configs:
|
|
202
205
|
|
203
206
|
```console
|
204
207
|
$ toml-combine example.toml --environment=production --service=frontend
|
208
|
+
```
|
209
|
+
|
210
|
+
```toml
|
205
211
|
registry = "gcr.io/my-project/"
|
206
212
|
service_account = "my-service-account"
|
207
213
|
name = "service-frontend"
|
@@ -212,6 +218,9 @@ image_name = "my-image-frontend"
|
|
212
218
|
|
213
219
|
```console
|
214
220
|
$ toml-combine example.toml --environment=production --service=backend
|
221
|
+
```
|
222
|
+
|
223
|
+
```toml
|
215
224
|
registry = "gcr.io/my-project/"
|
216
225
|
service_account = "my-service-account"
|
217
226
|
name = "service-backend"
|
@@ -223,6 +232,9 @@ port = 8080
|
|
223
232
|
|
224
233
|
```console
|
225
234
|
$ toml-combine example.toml --environment=staging --service=frontend
|
235
|
+
```
|
236
|
+
|
237
|
+
```toml
|
226
238
|
registry = "gcr.io/my-project/"
|
227
239
|
service_account = "my-service-account"
|
228
240
|
name = "service-frontend"
|
@@ -233,6 +245,9 @@ image_name = "my-image-frontend"
|
|
233
245
|
|
234
246
|
```console
|
235
247
|
$ toml-combine example.toml --environment=staging --service=backend
|
248
|
+
```
|
249
|
+
|
250
|
+
```toml
|
236
251
|
registry = "gcr.io/my-project/"
|
237
252
|
service_account = "my-service-account"
|
238
253
|
name = "service-backend"
|
@@ -247,6 +262,9 @@ ENABLE_EXPENSIVE_MONITORING = false
|
|
247
262
|
|
248
263
|
```console
|
249
264
|
$ toml-combine example.toml --environment=dev --service=backend
|
265
|
+
```
|
266
|
+
|
267
|
+
```toml
|
250
268
|
registry = "gcr.io/my-project/"
|
251
269
|
service_account = "my-service-account"
|
252
270
|
name = "service-backend"
|
@@ -257,5 +275,4 @@ port = 8080
|
|
257
275
|
[container.env]
|
258
276
|
DEBUG = true
|
259
277
|
ENABLE_EXPENSIVE_MONITORING = false
|
260
|
-
|
261
278
|
```
|
@@ -10,7 +10,7 @@ parts that are common to everyone.
|
|
10
10
|
|
11
11
|
### The config file
|
12
12
|
|
13
|
-
The configuration file is usually a TOML file. Here's a small example:
|
13
|
+
The configuration file is (usually) a TOML file. Here's a small example:
|
14
14
|
|
15
15
|
```toml
|
16
16
|
[dimensions]
|
@@ -43,14 +43,19 @@ The common configuration to start from, before we start overlaying overrides on
|
|
43
43
|
### Overrides
|
44
44
|
|
45
45
|
Overrides define a set of condition where they apply (`when.<dimension> =
|
46
|
-
"<value>"`) and the values that are
|
47
|
-
specific to more specific, each one overriding the values of the previous ones:
|
46
|
+
"<value>"`) and the values that are overridden when they're applicable.
|
48
47
|
|
49
|
-
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
48
|
+
- In case 2 overrides are applicable and define a value for the same key, if one is more
|
49
|
+
specific than the other (e.g. env=prod,region=us is more specific than env=prod) then
|
50
|
+
its values will have precedence.
|
51
|
+
- If they are mutually exclusive, (env=prod vs env=staging) precedence is irrelevant.
|
52
|
+
- If you try to generate the configuration and 2 applicable overrides define a value for
|
53
|
+
the same key, an error will be raised (e.g. env=staging and region=eu). In that case,
|
54
|
+
you should add dimensions to either override to make them mutually exclusive or make
|
55
|
+
one more specific than the other.
|
56
|
+
|
57
|
+
Note that it's not a problem if incompatible overrides exist in your configuration, as
|
58
|
+
long as they are not both applicable in the same call.
|
54
59
|
|
55
60
|
> [!Note]
|
56
61
|
> Defining a list as the value of one or more conditions in an override
|
@@ -58,14 +63,11 @@ specific to more specific, each one overriding the values of the previous ones:
|
|
58
63
|
|
59
64
|
### The configuration itself
|
60
65
|
|
61
|
-
Under the layer of `dimensions/default/override/mapping` system, what you actually
|
62
|
-
in the configuration is completely up to you. That said, only nested
|
66
|
+
Under the layer of `dimensions/default/override/mapping` system, what you actually
|
67
|
+
define in the configuration is completely up to you. That said, only nested
|
63
68
|
"dictionnaries"/"objects"/"tables"/"mapping" (those are all the same things in
|
64
|
-
Python/JS/Toml lingo) will be merged between the default and the overrides,
|
65
|
-
arrays will just replace one another. See `Arrays` below.
|
66
|
-
|
67
|
-
In the generated configuration, the dimensions of the output will appear in the generated
|
68
|
-
object as an object under the `dimensions` key.
|
69
|
+
Python/JS/Toml lingo) will be merged between the default and the applicable overrides,
|
70
|
+
while arrays will just replace one another. See `Arrays` below.
|
69
71
|
|
70
72
|
### Arrays
|
71
73
|
|
@@ -122,9 +124,9 @@ Example with the config from the previous section:
|
|
122
124
|
|
123
125
|
```console
|
124
126
|
$ toml-combine path/to/config.toml --environment=staging
|
125
|
-
|
126
|
-
environment = "staging"
|
127
|
+
```
|
127
128
|
|
129
|
+
```toml
|
128
130
|
[fruits]
|
129
131
|
apple.color = "red"
|
130
132
|
orange.color = "orange"
|
@@ -169,8 +171,9 @@ container.image_name = "my-image-backend"
|
|
169
171
|
container.port = 8080
|
170
172
|
|
171
173
|
[[override]]
|
172
|
-
|
174
|
+
when.service = "backend"
|
173
175
|
when.environment = "dev"
|
176
|
+
name = "service-dev"
|
174
177
|
container.env.DEBUG = true
|
175
178
|
|
176
179
|
[[override]]
|
@@ -183,6 +186,9 @@ This produces the following configs:
|
|
183
186
|
|
184
187
|
```console
|
185
188
|
$ toml-combine example.toml --environment=production --service=frontend
|
189
|
+
```
|
190
|
+
|
191
|
+
```toml
|
186
192
|
registry = "gcr.io/my-project/"
|
187
193
|
service_account = "my-service-account"
|
188
194
|
name = "service-frontend"
|
@@ -193,6 +199,9 @@ image_name = "my-image-frontend"
|
|
193
199
|
|
194
200
|
```console
|
195
201
|
$ toml-combine example.toml --environment=production --service=backend
|
202
|
+
```
|
203
|
+
|
204
|
+
```toml
|
196
205
|
registry = "gcr.io/my-project/"
|
197
206
|
service_account = "my-service-account"
|
198
207
|
name = "service-backend"
|
@@ -204,6 +213,9 @@ port = 8080
|
|
204
213
|
|
205
214
|
```console
|
206
215
|
$ toml-combine example.toml --environment=staging --service=frontend
|
216
|
+
```
|
217
|
+
|
218
|
+
```toml
|
207
219
|
registry = "gcr.io/my-project/"
|
208
220
|
service_account = "my-service-account"
|
209
221
|
name = "service-frontend"
|
@@ -214,6 +226,9 @@ image_name = "my-image-frontend"
|
|
214
226
|
|
215
227
|
```console
|
216
228
|
$ toml-combine example.toml --environment=staging --service=backend
|
229
|
+
```
|
230
|
+
|
231
|
+
```toml
|
217
232
|
registry = "gcr.io/my-project/"
|
218
233
|
service_account = "my-service-account"
|
219
234
|
name = "service-backend"
|
@@ -228,6 +243,9 @@ ENABLE_EXPENSIVE_MONITORING = false
|
|
228
243
|
|
229
244
|
```console
|
230
245
|
$ toml-combine example.toml --environment=dev --service=backend
|
246
|
+
```
|
247
|
+
|
248
|
+
```toml
|
231
249
|
registry = "gcr.io/my-project/"
|
232
250
|
service_account = "my-service-account"
|
233
251
|
name = "service-backend"
|
@@ -238,5 +256,4 @@ port = 8080
|
|
238
256
|
[container.env]
|
239
257
|
DEBUG = true
|
240
258
|
ENABLE_EXPENSIVE_MONITORING = false
|
241
|
-
|
242
259
|
```
|
@@ -49,6 +49,7 @@ cloudsql_instance = "staging-postgres"
|
|
49
49
|
containers.app.env.APP_FOO = "qux"
|
50
50
|
|
51
51
|
[[override]]
|
52
|
+
when.stack = "django"
|
52
53
|
# The following line defines when in an array. It's not useful, as there's only one
|
53
54
|
# value, but we want to test that arrays work too.
|
54
55
|
when.service = ["admin"]
|
@@ -57,6 +58,7 @@ containers.app.env.APP_ADMIN_ENABLED = true
|
|
57
58
|
containers.app.env.APP_ID = 1234
|
58
59
|
|
59
60
|
[[override]]
|
61
|
+
when.stack = "django"
|
60
62
|
when.service = "admin"
|
61
63
|
when.environment = "staging"
|
62
64
|
containers.app.env.APP_ID = 5678
|
@@ -66,11 +68,13 @@ when.type = "job"
|
|
66
68
|
containers.app.job.max_retries = 1
|
67
69
|
|
68
70
|
[[override]]
|
71
|
+
when.stack = "django"
|
69
72
|
when.job = "manage"
|
70
73
|
containers.app.name = "manage"
|
71
74
|
containers.app.command = ["./manage.py"]
|
72
75
|
|
73
76
|
[[override]]
|
77
|
+
when.stack = "django"
|
74
78
|
when.job = "special-command"
|
75
79
|
containers.app.name = "special-command"
|
76
80
|
containers.app.container_cpu = 8
|
@@ -8,7 +8,7 @@ from toml_combine import cli, toml
|
|
8
8
|
|
9
9
|
def test_cli__json(capsys):
|
10
10
|
"""Test the CLI."""
|
11
|
-
cli.cli(
|
11
|
+
exit_code = cli.cli(
|
12
12
|
argv=[
|
13
13
|
"tests/test.toml",
|
14
14
|
"--format",
|
@@ -28,6 +28,7 @@ def test_cli__json(capsys):
|
|
28
28
|
print(out)
|
29
29
|
print("err:")
|
30
30
|
print(err)
|
31
|
+
assert exit_code == 0
|
31
32
|
|
32
33
|
expected = json.loads((pathlib.Path(__file__).parent / "result.json").read_text())
|
33
34
|
assert json.loads(out) == expected["staging-service-django-admin"]
|
@@ -5,39 +5,6 @@ import pytest
|
|
5
5
|
from toml_combine import combiner, exceptions, toml
|
6
6
|
|
7
7
|
|
8
|
-
@pytest.mark.parametrize(
|
9
|
-
"small_override, large_override, dimensions",
|
10
|
-
[
|
11
|
-
pytest.param(
|
12
|
-
{"env": "prod"},
|
13
|
-
{"env": "prod", "region": "eu"},
|
14
|
-
{"env": ["prod"], "region": ["eu"]},
|
15
|
-
id="less_specific_override_comes_first",
|
16
|
-
),
|
17
|
-
pytest.param(
|
18
|
-
{"env": "prod", "region": "eu"},
|
19
|
-
{"env": "prod", "service": "web"},
|
20
|
-
{"env": ["prod"], "region": ["eu"], "service": ["web"]},
|
21
|
-
id="different_dimensions_sorted_by_dimension",
|
22
|
-
),
|
23
|
-
pytest.param(
|
24
|
-
{"env": "prod"},
|
25
|
-
{"region": "eu"},
|
26
|
-
{"env": ["prod"], "region": ["eu"]},
|
27
|
-
id="completely_different_dimensions",
|
28
|
-
),
|
29
|
-
],
|
30
|
-
)
|
31
|
-
def test_override_sort_key(small_override, large_override, dimensions):
|
32
|
-
small_key = combiner.override_sort_key(
|
33
|
-
combiner.Override(when=small_override, config={}), dimensions
|
34
|
-
)
|
35
|
-
large_key = combiner.override_sort_key(
|
36
|
-
combiner.Override(when=large_override, config={}), dimensions
|
37
|
-
)
|
38
|
-
assert small_key < large_key
|
39
|
-
|
40
|
-
|
41
8
|
@pytest.mark.parametrize(
|
42
9
|
"a, b, expected",
|
43
10
|
[
|
@@ -103,7 +70,7 @@ def test_merge_configs__dicts_error():
|
|
103
70
|
),
|
104
71
|
],
|
105
72
|
)
|
106
|
-
def
|
73
|
+
def __full_chain(mapping: dict, expected: dict[str, int]):
|
107
74
|
default = {
|
108
75
|
"a": 1,
|
109
76
|
"b": 2,
|
@@ -138,8 +105,11 @@ def test_generate_for_mapping(mapping: dict, expected: dict[str, int]):
|
|
138
105
|
]
|
139
106
|
|
140
107
|
result = combiner.generate_for_mapping(
|
141
|
-
|
142
|
-
|
108
|
+
config=combiner.Config(
|
109
|
+
dimensions={"env": ["prod", "staging"], "region": ["us"]},
|
110
|
+
default=default,
|
111
|
+
overrides=overrides,
|
112
|
+
),
|
143
113
|
mapping=mapping,
|
144
114
|
)
|
145
115
|
assert result == expected
|
@@ -221,13 +191,10 @@ def test_build_config():
|
|
221
191
|
)
|
222
192
|
|
223
193
|
|
224
|
-
def
|
194
|
+
def test_generate_for_mapping__duplicate_overrides():
|
225
195
|
raw_config = """
|
226
196
|
[dimensions]
|
227
|
-
env = ["
|
228
|
-
|
229
|
-
[templates]
|
230
|
-
foo = "bar"
|
197
|
+
env = ["prod"]
|
231
198
|
|
232
199
|
[[override]]
|
233
200
|
when.env = "prod"
|
@@ -238,9 +205,58 @@ def test_build_config__duplicate_overrides():
|
|
238
205
|
foo = "qux"
|
239
206
|
"""
|
240
207
|
|
241
|
-
config = toml.loads(raw_config)
|
208
|
+
config = combiner.build_config(toml.loads(raw_config))
|
242
209
|
with pytest.raises(exceptions.DuplicateError):
|
243
|
-
combiner.
|
210
|
+
combiner.generate_for_mapping(config=config, mapping={"env": "prod"})
|
211
|
+
|
212
|
+
|
213
|
+
def test_build_config__duplicate_overrides_different_vars():
|
214
|
+
raw_config = """
|
215
|
+
[dimensions]
|
216
|
+
env = ["prod"]
|
217
|
+
|
218
|
+
[[override]]
|
219
|
+
when.env = "prod"
|
220
|
+
foo = "baz"
|
221
|
+
|
222
|
+
[[override]]
|
223
|
+
when.env = "prod"
|
224
|
+
baz = "qux"
|
225
|
+
"""
|
226
|
+
|
227
|
+
config = combiner.build_config(toml.loads(raw_config))
|
228
|
+
assert combiner.generate_for_mapping(config=config, mapping={"env": "prod"}) == {
|
229
|
+
"foo": "baz",
|
230
|
+
"baz": "qux",
|
231
|
+
}
|
232
|
+
|
233
|
+
|
234
|
+
def test_build_config__duplicate_overrides_list():
|
235
|
+
raw_config = """
|
236
|
+
[dimensions]
|
237
|
+
env = ["prod", "dev"]
|
238
|
+
|
239
|
+
[[override]]
|
240
|
+
when.env = ["prod"]
|
241
|
+
hello.world = 1
|
242
|
+
|
243
|
+
[[override]]
|
244
|
+
when.env = ["prod", "dev"]
|
245
|
+
hello.world = 2
|
246
|
+
"""
|
247
|
+
|
248
|
+
config = combiner.build_config(toml.loads(raw_config))
|
249
|
+
with pytest.raises(exceptions.DuplicateError) as excinfo:
|
250
|
+
combiner.generate_for_mapping(config=config, mapping={"env": "prod"})
|
251
|
+
|
252
|
+
# Message is a bit complex so we test it too.
|
253
|
+
assert (
|
254
|
+
str(excinfo.value)
|
255
|
+
== "In override {'env': ['prod', 'dev']}: Overrides defining the same "
|
256
|
+
"configuration keys must be included in one another or mutually exclusive.\n"
|
257
|
+
"Key defined multiple times: hello.world\n"
|
258
|
+
"Other override: {'env': ['prod']}"
|
259
|
+
)
|
244
260
|
|
245
261
|
|
246
262
|
def test_build_config__dimension_not_found_in_override():
|
@@ -271,16 +287,109 @@ def test_build_config__dimension_value_not_found_in_override():
|
|
271
287
|
combiner.build_config(config)
|
272
288
|
|
273
289
|
|
274
|
-
@pytest.
|
275
|
-
|
276
|
-
|
290
|
+
@pytest.mark.parametrize(
|
291
|
+
"mapping, expected",
|
292
|
+
[
|
293
|
+
(
|
294
|
+
{"env": "prod"},
|
295
|
+
{"foo": "bar"},
|
296
|
+
),
|
297
|
+
(
|
298
|
+
{"env": "dev"},
|
299
|
+
{"foo": "baz"},
|
300
|
+
),
|
301
|
+
],
|
302
|
+
)
|
303
|
+
def test_generate_for_mapping__full_chain(mapping, expected):
|
304
|
+
config = combiner.build_config(
|
277
305
|
toml.loads(
|
278
306
|
"""
|
279
|
-
|
280
|
-
|
307
|
+
[dimensions]
|
308
|
+
env = ["prod", "dev"]
|
281
309
|
|
282
|
-
|
283
|
-
|
284
|
-
|
310
|
+
[default]
|
311
|
+
foo = "bar"
|
312
|
+
|
313
|
+
[[override]]
|
314
|
+
when.env = "dev"
|
315
|
+
foo = "baz"
|
316
|
+
""",
|
285
317
|
)
|
286
318
|
)
|
319
|
+
result = combiner.generate_for_mapping(
|
320
|
+
config=config,
|
321
|
+
mapping=mapping,
|
322
|
+
)
|
323
|
+
assert result == expected
|
324
|
+
|
325
|
+
|
326
|
+
def test_extract_keys():
|
327
|
+
config = toml.loads(
|
328
|
+
"""
|
329
|
+
a = 1
|
330
|
+
b.c = 1
|
331
|
+
b.d = 1
|
332
|
+
e.f.g = 1
|
333
|
+
""",
|
334
|
+
)
|
335
|
+
|
336
|
+
result = list(combiner.extract_keys(config))
|
337
|
+
assert result == [
|
338
|
+
("a",),
|
339
|
+
("b", "c"),
|
340
|
+
("b", "d"),
|
341
|
+
("e", "f", "g"),
|
342
|
+
]
|
343
|
+
|
344
|
+
|
345
|
+
@pytest.mark.parametrize(
|
346
|
+
"a, b, expected",
|
347
|
+
[
|
348
|
+
pytest.param(
|
349
|
+
{"env": ["dev"], "region": ["eu"]},
|
350
|
+
{"env": ["dev"]},
|
351
|
+
True,
|
352
|
+
id="subset1",
|
353
|
+
),
|
354
|
+
pytest.param(
|
355
|
+
{"env": ["dev"]},
|
356
|
+
{"env": ["dev"], "region": ["eu"]},
|
357
|
+
True,
|
358
|
+
id="subset2",
|
359
|
+
),
|
360
|
+
pytest.param(
|
361
|
+
{"env": ["prod"], "region": ["eu"]},
|
362
|
+
{"env": ["dev"]},
|
363
|
+
True,
|
364
|
+
id="subset3",
|
365
|
+
),
|
366
|
+
pytest.param(
|
367
|
+
{"env": ["dev"]},
|
368
|
+
{"env": ["prod"], "region": ["eu"]},
|
369
|
+
True,
|
370
|
+
id="subset4",
|
371
|
+
),
|
372
|
+
pytest.param({"env": ["dev"]}, {"region": ["eu"]}, False, id="disjoint"),
|
373
|
+
pytest.param(
|
374
|
+
{"env": ["dev"], "service": ["frontend"]},
|
375
|
+
{"region": ["eu"], "service": ["frontend"]},
|
376
|
+
False,
|
377
|
+
id="overlap",
|
378
|
+
),
|
379
|
+
pytest.param({"env": ["dev"]}, {"env": ["dev"]}, False, id="same_keys1"),
|
380
|
+
pytest.param(
|
381
|
+
{"env": ["dev", "prod"]}, {"env": ["dev"]}, False, id="same_keys1"
|
382
|
+
),
|
383
|
+
pytest.param(
|
384
|
+
{"env": ["prod"]}, {"env": ["dev"]}, True, id="same_keys_disjoint"
|
385
|
+
),
|
386
|
+
pytest.param(
|
387
|
+
{"env": ["prod", "staging"]},
|
388
|
+
{"env": ["dev", "sandbox"]},
|
389
|
+
True,
|
390
|
+
id="multiple_keys_disjoint",
|
391
|
+
),
|
392
|
+
],
|
393
|
+
)
|
394
|
+
def test_are_conditions_compatible(a, b, expected):
|
395
|
+
assert combiner.are_conditions_compatible(a, b) == expected
|
@@ -16,23 +16,19 @@ def expected():
|
|
16
16
|
return json.loads((pathlib.Path(__file__).parent / "result.json").read_text())
|
17
17
|
|
18
18
|
|
19
|
-
@pytest.mark.parametrize(
|
20
|
-
"kwargs",
|
21
|
-
[
|
22
|
-
{"config_file": config_file},
|
23
|
-
{"config_file": str(config_file)},
|
24
|
-
{"config": config_file.read_text()},
|
25
|
-
{"config": toml.loads(config_file.read_text())},
|
26
|
-
],
|
27
|
-
)
|
28
19
|
@pytest.mark.parametrize(
|
29
20
|
"mapping, expected_key",
|
30
21
|
[
|
31
|
-
(
|
32
|
-
{
|
22
|
+
pytest.param(
|
23
|
+
{
|
24
|
+
"environment": "staging",
|
25
|
+
"type": "service",
|
26
|
+
"stack": "next",
|
27
|
+
},
|
33
28
|
"staging-service-next",
|
29
|
+
id="staging-service-next",
|
34
30
|
),
|
35
|
-
(
|
31
|
+
pytest.param(
|
36
32
|
{
|
37
33
|
"environment": "staging",
|
38
34
|
"type": "service",
|
@@ -40,8 +36,9 @@ def expected():
|
|
40
36
|
"service": "api",
|
41
37
|
},
|
42
38
|
"staging-service-django-api",
|
39
|
+
id="staging-service-django-api",
|
43
40
|
),
|
44
|
-
(
|
41
|
+
pytest.param(
|
45
42
|
{
|
46
43
|
"environment": "staging",
|
47
44
|
"type": "service",
|
@@ -49,8 +46,9 @@ def expected():
|
|
49
46
|
"service": "admin",
|
50
47
|
},
|
51
48
|
"staging-service-django-admin",
|
49
|
+
id="staging-service-django-admin",
|
52
50
|
),
|
53
|
-
(
|
51
|
+
pytest.param(
|
54
52
|
{
|
55
53
|
"environment": "staging",
|
56
54
|
"type": "job",
|
@@ -58,8 +56,9 @@ def expected():
|
|
58
56
|
"job": "manage",
|
59
57
|
},
|
60
58
|
"staging-job-django-manage",
|
59
|
+
id="staging-job-django-manage",
|
61
60
|
),
|
62
|
-
(
|
61
|
+
pytest.param(
|
63
62
|
{
|
64
63
|
"environment": "staging",
|
65
64
|
"type": "job",
|
@@ -67,12 +66,18 @@ def expected():
|
|
67
66
|
"job": "special-command",
|
68
67
|
},
|
69
68
|
"staging-job-django-special-command",
|
69
|
+
id="staging-job-django-special-command",
|
70
70
|
),
|
71
|
-
(
|
72
|
-
{
|
71
|
+
pytest.param(
|
72
|
+
{
|
73
|
+
"environment": "production",
|
74
|
+
"type": "service",
|
75
|
+
"stack": "next",
|
76
|
+
},
|
73
77
|
"production-service-next",
|
78
|
+
id="production-service-next",
|
74
79
|
),
|
75
|
-
(
|
80
|
+
pytest.param(
|
76
81
|
{
|
77
82
|
"environment": "production",
|
78
83
|
"type": "service",
|
@@ -80,8 +85,9 @@ def expected():
|
|
80
85
|
"service": "api",
|
81
86
|
},
|
82
87
|
"production-service-django-api",
|
88
|
+
id="production-service-django-api",
|
83
89
|
),
|
84
|
-
(
|
90
|
+
pytest.param(
|
85
91
|
{
|
86
92
|
"environment": "production",
|
87
93
|
"type": "service",
|
@@ -89,8 +95,9 @@ def expected():
|
|
89
95
|
"service": "admin",
|
90
96
|
},
|
91
97
|
"production-service-django-admin",
|
98
|
+
id="production-service-django-admin",
|
92
99
|
),
|
93
|
-
(
|
100
|
+
pytest.param(
|
94
101
|
{
|
95
102
|
"environment": "production",
|
96
103
|
"type": "job",
|
@@ -98,8 +105,9 @@ def expected():
|
|
98
105
|
"job": "manage",
|
99
106
|
},
|
100
107
|
"production-job-django-manage",
|
108
|
+
id="production-job-django-manage",
|
101
109
|
),
|
102
|
-
(
|
110
|
+
pytest.param(
|
103
111
|
{
|
104
112
|
"environment": "production",
|
105
113
|
"type": "job",
|
@@ -107,9 +115,30 @@ def expected():
|
|
107
115
|
"job": "special-command",
|
108
116
|
},
|
109
117
|
"production-job-django-special-command",
|
118
|
+
id="production-job-django-special-command",
|
110
119
|
),
|
111
120
|
],
|
112
121
|
)
|
113
|
-
def
|
114
|
-
result = toml_combine.combine(
|
122
|
+
def test_full_config(mapping, expected, expected_key):
|
123
|
+
result = toml_combine.combine(config_file=config_file, **mapping)
|
115
124
|
assert result == expected[expected_key]
|
125
|
+
|
126
|
+
|
127
|
+
@pytest.mark.parametrize(
|
128
|
+
"kwargs",
|
129
|
+
[
|
130
|
+
pytest.param({"config_file": config_file}, id="path"),
|
131
|
+
pytest.param({"config_file": str(config_file)}, id="path_str"),
|
132
|
+
pytest.param({"config": config_file.read_text()}, id="text"),
|
133
|
+
pytest.param({"config": toml.loads(config_file.read_text())}, id="parsed"),
|
134
|
+
],
|
135
|
+
)
|
136
|
+
def test_full_load_kwargs(kwargs, expected):
|
137
|
+
result = toml_combine.combine(
|
138
|
+
**kwargs,
|
139
|
+
environment="production",
|
140
|
+
type="service",
|
141
|
+
stack="django",
|
142
|
+
service="api",
|
143
|
+
)
|
144
|
+
assert result == expected["production-service-django-api"]
|
@@ -2,8 +2,7 @@ from __future__ import annotations
|
|
2
2
|
|
3
3
|
import copy
|
4
4
|
import dataclasses
|
5
|
-
from collections.abc import Mapping, Sequence
|
6
|
-
from functools import partial
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
7
6
|
from typing import Any, TypeVar
|
8
7
|
|
9
8
|
from . import exceptions
|
@@ -59,36 +58,6 @@ def clean_dimensions_dict(
|
|
59
58
|
return result
|
60
59
|
|
61
60
|
|
62
|
-
def override_sort_key(
|
63
|
-
override: Override, dimensions: dict[str, list[str]]
|
64
|
-
) -> tuple[int, ...]:
|
65
|
-
"""
|
66
|
-
We sort overrides before applying them, and they are applied in the order of the
|
67
|
-
sorted list, each override replacing the common values of the previous overrides.
|
68
|
-
|
69
|
-
override_sort_key defines the sort key for overrides that ensures less specific
|
70
|
-
overrides come first:
|
71
|
-
- Overrides with fewer dimensions come first (will be overridden
|
72
|
-
by more specific ones)
|
73
|
-
- If two overrides have the same number of dimensions but define different
|
74
|
-
dimensions, we sort by the definition order of the dimensions.
|
75
|
-
|
76
|
-
Example:
|
77
|
-
dimensions = {"env": ["dev", "prod"], "region": ["us", "eu"]}
|
78
|
-
|
79
|
-
- Override with {"env": "dev"} comes before override with
|
80
|
-
{"env": "dev", "region": "us"} (less specific)
|
81
|
-
- Override with {"env": "dev"} comes before override with {"region": "us"} ("env"
|
82
|
-
is defined before "region" in the dimensions list)
|
83
|
-
"""
|
84
|
-
result = [len(override.when)]
|
85
|
-
for i, dimension in enumerate(dimensions):
|
86
|
-
if dimension in override.when:
|
87
|
-
result.append(i)
|
88
|
-
|
89
|
-
return tuple(result)
|
90
|
-
|
91
|
-
|
92
61
|
T = TypeVar("T", dict, list, str, int, float, bool)
|
93
62
|
|
94
63
|
|
@@ -111,6 +80,41 @@ def merge_configs(a: T, b: T, /) -> T:
|
|
111
80
|
return result
|
112
81
|
|
113
82
|
|
83
|
+
def extract_keys(config: Any) -> Iterable[tuple[str, ...]]:
|
84
|
+
"""
|
85
|
+
Extract the keys from a config.
|
86
|
+
"""
|
87
|
+
if isinstance(config, dict):
|
88
|
+
for key, value in config.items():
|
89
|
+
for sub_key in extract_keys(value):
|
90
|
+
yield (key, *sub_key)
|
91
|
+
else:
|
92
|
+
yield tuple()
|
93
|
+
|
94
|
+
|
95
|
+
def are_conditions_compatible(
|
96
|
+
a: Mapping[str, list[str]], b: Mapping[str, list[str]], /
|
97
|
+
) -> bool:
|
98
|
+
"""
|
99
|
+
`a` and `b` are dictionaries representing override conditions (`when`). Return
|
100
|
+
`True` if the conditions represented by `a` are compatible. Conditions are
|
101
|
+
compatible if one is stricly more specific than the other or if they're mutually
|
102
|
+
exclusive.
|
103
|
+
"""
|
104
|
+
# Subset
|
105
|
+
if set(a) < set(b) or set(b) < set(a):
|
106
|
+
return True
|
107
|
+
|
108
|
+
# Disjoint or overlapping sets
|
109
|
+
if set(a) != set(b):
|
110
|
+
return False
|
111
|
+
|
112
|
+
# Equal sets: it's only compatible if the values are disjoint
|
113
|
+
if any(set(a[key]) & set(b[key]) for key in a.keys()):
|
114
|
+
return False
|
115
|
+
return True
|
116
|
+
|
117
|
+
|
114
118
|
def build_config(config: dict[str, Any]) -> Config:
|
115
119
|
config = copy.deepcopy(config)
|
116
120
|
# Parse dimensions
|
@@ -119,7 +123,6 @@ def build_config(config: dict[str, Any]) -> Config:
|
|
119
123
|
# Parse template
|
120
124
|
default = config.pop("default", {})
|
121
125
|
|
122
|
-
seen_conditions = set()
|
123
126
|
overrides = []
|
124
127
|
for override in config.pop("override", []):
|
125
128
|
try:
|
@@ -132,25 +135,10 @@ def build_config(config: dict[str, Any]) -> Config:
|
|
132
135
|
type="override",
|
133
136
|
)
|
134
137
|
|
135
|
-
|
136
|
-
if conditions in seen_conditions:
|
137
|
-
raise exceptions.DuplicateError(type="override", id=when)
|
138
|
-
|
139
|
-
seen_conditions.add(conditions)
|
138
|
+
overrides.append(Override(when=when, config=override))
|
140
139
|
|
141
|
-
overrides.append(
|
142
|
-
Override(
|
143
|
-
when=clean_dimensions_dict(
|
144
|
-
to_sort=when, clean=dimensions, type="override"
|
145
|
-
),
|
146
|
-
config=override,
|
147
|
-
)
|
148
|
-
)
|
149
140
|
# Sort overrides by increasing specificity
|
150
|
-
overrides = sorted(
|
151
|
-
overrides,
|
152
|
-
key=partial(override_sort_key, dimensions=dimensions),
|
153
|
-
)
|
141
|
+
overrides = sorted(overrides, key=lambda override: len(override.when))
|
154
142
|
|
155
143
|
return Config(
|
156
144
|
dimensions=dimensions,
|
@@ -159,7 +147,7 @@ def build_config(config: dict[str, Any]) -> Config:
|
|
159
147
|
)
|
160
148
|
|
161
149
|
|
162
|
-
def mapping_matches_override(mapping:
|
150
|
+
def mapping_matches_override(mapping: Mapping[str, str], override: Override) -> bool:
|
163
151
|
"""
|
164
152
|
Check if the values in the override match the given dimensions.
|
165
153
|
"""
|
@@ -174,16 +162,32 @@ def mapping_matches_override(mapping: dict[str, str], override: Override) -> boo
|
|
174
162
|
|
175
163
|
|
176
164
|
def generate_for_mapping(
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
165
|
+
config: Config,
|
166
|
+
mapping: Mapping[str, str],
|
167
|
+
) -> Mapping[str, Any]:
|
168
|
+
result = copy.deepcopy(config.default)
|
169
|
+
keys_to_conditions: dict[tuple[str, ...], list[dict[str, list[str]]]] = {}
|
182
170
|
# Apply each matching override
|
183
|
-
for override in overrides:
|
171
|
+
for override in config.overrides:
|
184
172
|
# Check if all dimension values in the override match
|
185
173
|
|
186
174
|
if mapping_matches_override(mapping=mapping, override=override):
|
175
|
+
# Check that all applicableoverrides are compatible
|
176
|
+
keys = extract_keys(override.config)
|
177
|
+
|
178
|
+
for key in keys:
|
179
|
+
previous_conditions = keys_to_conditions.setdefault(key, [])
|
180
|
+
|
181
|
+
for previous_condition in previous_conditions:
|
182
|
+
if not are_conditions_compatible(previous_condition, override.when):
|
183
|
+
raise exceptions.DuplicateError(
|
184
|
+
id=override.when,
|
185
|
+
key=".".join(key),
|
186
|
+
other_override=previous_condition,
|
187
|
+
)
|
188
|
+
|
189
|
+
keys_to_conditions[key].append(override.when)
|
190
|
+
|
187
191
|
result = merge_configs(result, override.config)
|
188
192
|
|
189
193
|
return result
|
@@ -20,7 +20,7 @@ class TomlEncodeError(TomlCombineError):
|
|
20
20
|
|
21
21
|
|
22
22
|
class DuplicateError(TomlCombineError):
|
23
|
-
"""In
|
23
|
+
"""In override {id}: Overrides defining the same configuration keys must be included in one another or mutually exclusive.\nKey defined multiple times: {key}\nOther override: {other_override}"""
|
24
24
|
|
25
25
|
|
26
26
|
class DimensionNotFound(TomlCombineError):
|
@@ -52,7 +52,6 @@ def combine(*, config=None, config_file=None, **mapping):
|
|
52
52
|
config_obj = combiner.build_config(dict_config)
|
53
53
|
|
54
54
|
return combiner.generate_for_mapping(
|
55
|
-
|
56
|
-
overrides=config_obj.overrides,
|
55
|
+
config=config_obj,
|
57
56
|
mapping=mapping,
|
58
57
|
)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|