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.
@@ -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.13
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.4
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.4.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 overriden. Overrides are applied in order from less
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
- - If an override contains conditions on more dimensions than another one, it's applied
69
- later
70
- - In case 2 overrides contain the same number of dimensions and they're a disjoint set,
71
- then it depends on how the dimensions are defined at the top of the file: dimensions
72
- defined last have a greater priority
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 define
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, while
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
- [dimensions]
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
- name = "service-dev"
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 overriden. Overrides are applied in order from less
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
- - If an override contains conditions on more dimensions than another one, it's applied
50
- later
51
- - In case 2 overrides contain the same number of dimensions and they're a disjoint set,
52
- then it depends on how the dimensions are defined at the top of the file: dimensions
53
- defined last have a greater priority
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 define
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, while
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
- [dimensions]
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
- name = "service-dev"
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 test_generate_for_mapping(mapping: dict, expected: dict[str, int]):
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
- default=default,
142
- overrides=overrides,
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 test_build_config__duplicate_overrides():
194
+ def test_generate_for_mapping__duplicate_overrides():
225
195
  raw_config = """
226
196
  [dimensions]
227
- env = ["dev", "prod"]
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.build_config(config)
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.fixture
275
- def config():
276
- return combiner.build_config(
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
- [dimensions]
280
- env = ["dev", "prod"]
307
+ [dimensions]
308
+ env = ["prod", "dev"]
281
309
 
282
- [default]
283
- foo = "bar"
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
- {"environment": "staging", "type": "service", "stack": "next"},
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
- {"environment": "production", "type": "service", "stack": "next"},
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 test_full(kwargs, mapping, expected, expected_key):
114
- result = toml_combine.combine(**kwargs, **mapping)
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
- conditions = tuple((k, tuple(v)) for k, v in when.items())
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: dict[str, str], override: Override) -> bool:
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
- default: Mapping[str, Any],
178
- overrides: Sequence[Override],
179
- mapping: dict[str, str],
180
- ) -> dict[str, Any]:
181
- result = copy.deepcopy(default)
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 {type} {id}: Cannot have multiple {type}s with the same dimensions."""
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
- default=config_obj.default,
56
- overrides=config_obj.overrides,
55
+ config=config_obj,
57
56
  mapping=mapping,
58
57
  )
File without changes