toml-combine 0.5.0__tar.gz → 1.0.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.
Files changed (22) hide show
  1. toml_combine-1.0.0/CONTRIBUTING.md +34 -0
  2. toml_combine-1.0.0/LICENSE +7 -0
  3. {toml_combine-0.5.0 → toml_combine-1.0.0}/PKG-INFO +27 -20
  4. {toml_combine-0.5.0 → toml_combine-1.0.0}/README.md +25 -19
  5. {toml_combine-0.5.0 → toml_combine-1.0.0}/tests/test.toml +4 -0
  6. {toml_combine-0.5.0 → toml_combine-1.0.0}/tests/test_cli.py +2 -1
  7. {toml_combine-0.5.0 → toml_combine-1.0.0}/tests/test_combiner.py +229 -227
  8. {toml_combine-0.5.0 → toml_combine-1.0.0}/tests/test_lib.py +52 -23
  9. {toml_combine-0.5.0 → toml_combine-1.0.0}/toml_combine/combiner.py +36 -72
  10. {toml_combine-0.5.0 → toml_combine-1.0.0}/toml_combine/exceptions.py +2 -2
  11. {toml_combine-0.5.0 → toml_combine-1.0.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  12. {toml_combine-0.5.0 → toml_combine-1.0.0}/.github/renovate.json5 +0 -0
  13. {toml_combine-0.5.0 → toml_combine-1.0.0}/.github/workflows/ci.yml +0 -0
  14. {toml_combine-0.5.0 → toml_combine-1.0.0}/.pre-commit-config.yaml +0 -0
  15. {toml_combine-0.5.0 → toml_combine-1.0.0}/pyproject.toml +0 -0
  16. {toml_combine-0.5.0 → toml_combine-1.0.0}/tests/result.json +0 -0
  17. {toml_combine-0.5.0 → toml_combine-1.0.0}/toml_combine/__init__.py +0 -0
  18. {toml_combine-0.5.0 → toml_combine-1.0.0}/toml_combine/__main__.py +0 -0
  19. {toml_combine-0.5.0 → toml_combine-1.0.0}/toml_combine/cli.py +0 -0
  20. {toml_combine-0.5.0 → toml_combine-1.0.0}/toml_combine/lib.py +0 -0
  21. {toml_combine-0.5.0 → toml_combine-1.0.0}/toml_combine/toml.py +0 -0
  22. {toml_combine-0.5.0 → toml_combine-1.0.0}/uv.lock +0 -0
@@ -0,0 +1,34 @@
1
+ ## Development
2
+
3
+ This project uses [uv](https://docs.astral.sh/uv/).
4
+
5
+ If you don't have uv installed, install it with:
6
+
7
+ ```console
8
+ $ curl -LsSf https://astral.sh/uv/install.sh | sh
9
+ ```
10
+
11
+ or using a package manager, such as [brew](https://brew.sh/):
12
+
13
+ ```console
14
+ $ brew install uv
15
+ ```
16
+
17
+ Then you can directly launch the tests with:
18
+
19
+ ```console
20
+ $ uv run pytest
21
+ ```
22
+
23
+ or create a virtual environment to work on the package and activate it:
24
+
25
+ ```console
26
+ $ uv sync
27
+ $ source .venv/bin/activate
28
+ ```
29
+
30
+ or launch the command itself with uv (no need to activate the venv):
31
+
32
+ ```console
33
+ $ uv run toml-combine --help
34
+ ```
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2025- Joachim Jablon
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: toml-combine
3
- Version: 0.5.0
3
+ Version: 1.0.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
7
+ License-File: LICENSE
7
8
  Classifier: Development Status :: 4 - Beta
8
9
  Classifier: Intended Audience :: Developers
9
10
  Classifier: License :: OSI Approved :: MIT License
@@ -19,6 +20,12 @@ Description-Content-Type: text/markdown
19
20
 
20
21
  # Toml-combine
21
22
 
23
+ [![Deployed to PyPI](https://img.shields.io/pypi/v/toml-combine?logo=pypi&logoColor=white)](https://pypi.org/pypi/toml-combine)
24
+ [![Deployed to PyPI](https://img.shields.io/pypi/pyversions/toml-combine?logo=pypi&logoColor=white)](https://pypi.org/pypi/toml-combine)
25
+ [![GitHub Repository](https://img.shields.io/github/stars/ewjoachim/toml-combine?style=flat&logo=github&color=brightgreen)](https://github.com/ewjoachim/toml-combine/)
26
+ [![Continuous Integration](https://img.shields.io/github/actions/workflow/status/ewjoachim/toml-combine/ci.yml?logo=github&branch=main)](https://github.com/ewjoachim/toml-combine/actions?workflow=CI)
27
+ [![MIT License](https://img.shields.io/github/license/ewjoachim/toml-combine?logo=open-source-initiative&logoColor=white)](https://github.com/ewjoachim/toml-combine/blob/main/LICENSE)
28
+
22
29
  `toml-combine` is a Python lib and CLI-tool that reads a TOML configuration file
23
30
  defining a default configuration alongside with overrides, and merges everything
24
31
  following rules you define to get final configurations. Let's say: you have multiple
@@ -62,17 +69,19 @@ The common configuration to start from, before we start overlaying overrides on
62
69
  ### Overrides
63
70
 
64
71
  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:
67
-
68
- - In case 2 overrides are applicable, the more specific one (the one with more
69
- dimensions defined) has greater priority
70
- - In case 2 overrides use the same number of dimensions, then it depends on how the
71
- dimensions are defined at the top of the file: dimensions defined last have a greater
72
- priority
73
- - In case 2 overrides use the same dimensions, if they define the same configuration
74
- values, an error will be raised. If they define different configuation values, then
75
- the priority is irrelevant.
72
+ "<value>"`) and the values that are overridgden when they're applicable.
73
+
74
+ - In case 2 overrides are applicable and define a value for the same key, if one is more
75
+ specific than the other (e.g. env=prod,region=us is more specific than env=prod) then
76
+ its values will have precedence.
77
+ - If they are mutually exclusive, (env=prod vs env=staging) precedence is irrelevant.
78
+ - If you try to generate the configuration and 2 applicable overrides define a value for
79
+ the same key, an error will be raised (e.g. env=staging and region=eu). In that case,
80
+ you should add dimensions to either override to make them mutually exclusive or make
81
+ one more specific than the other.
82
+
83
+ Note that it's not a problem if incompatible overrides exist in your configuration, as
84
+ long as they are not both applicable in the same call.
76
85
 
77
86
  > [!Note]
78
87
  > Defining a list as the value of one or more conditions in an override
@@ -80,14 +89,11 @@ specific to more specific, each one overriding the values of the previous ones:
80
89
 
81
90
  ### The configuration itself
82
91
 
83
- Under the layer of `dimensions/default/override/mapping` system, what you actually define
84
- in the configuration is completely up to you. That said, only nested
92
+ Under the layer of `dimensions/default/override/mapping` system, what you actually
93
+ define in the configuration is completely up to you. That said, only nested
85
94
  "dictionnaries"/"objects"/"tables"/"mapping" (those are all the same things in
86
- Python/JS/Toml lingo) will be merged between the default and the overrides, while
87
- arrays will just replace one another. See `Arrays` below.
88
-
89
- In the generated configuration, the dimensions of the output will appear in the generated
90
- object as an object under the `dimensions` key.
95
+ Python/JS/Toml lingo) will be merged between the default and the applicable overrides,
96
+ while arrays will just replace one another. See `Arrays` below.
91
97
 
92
98
  ### Arrays
93
99
 
@@ -191,8 +197,9 @@ container.image_name = "my-image-backend"
191
197
  container.port = 8080
192
198
 
193
199
  [[override]]
194
- name = "service-dev"
200
+ when.service = "backend"
195
201
  when.environment = "dev"
202
+ name = "service-dev"
196
203
  container.env.DEBUG = true
197
204
 
198
205
  [[override]]
@@ -1,5 +1,11 @@
1
1
  # Toml-combine
2
2
 
3
+ [![Deployed to PyPI](https://img.shields.io/pypi/v/toml-combine?logo=pypi&logoColor=white)](https://pypi.org/pypi/toml-combine)
4
+ [![Deployed to PyPI](https://img.shields.io/pypi/pyversions/toml-combine?logo=pypi&logoColor=white)](https://pypi.org/pypi/toml-combine)
5
+ [![GitHub Repository](https://img.shields.io/github/stars/ewjoachim/toml-combine?style=flat&logo=github&color=brightgreen)](https://github.com/ewjoachim/toml-combine/)
6
+ [![Continuous Integration](https://img.shields.io/github/actions/workflow/status/ewjoachim/toml-combine/ci.yml?logo=github&branch=main)](https://github.com/ewjoachim/toml-combine/actions?workflow=CI)
7
+ [![MIT License](https://img.shields.io/github/license/ewjoachim/toml-combine?logo=open-source-initiative&logoColor=white)](https://github.com/ewjoachim/toml-combine/blob/main/LICENSE)
8
+
3
9
  `toml-combine` is a Python lib and CLI-tool that reads a TOML configuration file
4
10
  defining a default configuration alongside with overrides, and merges everything
5
11
  following rules you define to get final configurations. Let's say: you have multiple
@@ -43,17 +49,19 @@ The common configuration to start from, before we start overlaying overrides on
43
49
  ### Overrides
44
50
 
45
51
  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:
48
-
49
- - In case 2 overrides are applicable, the more specific one (the one with more
50
- dimensions defined) has greater priority
51
- - In case 2 overrides use the same number of dimensions, then it depends on how the
52
- dimensions are defined at the top of the file: dimensions defined last have a greater
53
- priority
54
- - In case 2 overrides use the same dimensions, if they define the same configuration
55
- values, an error will be raised. If they define different configuation values, then
56
- the priority is irrelevant.
52
+ "<value>"`) and the values that are overridgden when they're applicable.
53
+
54
+ - In case 2 overrides are applicable and define a value for the same key, if one is more
55
+ specific than the other (e.g. env=prod,region=us is more specific than env=prod) then
56
+ its values will have precedence.
57
+ - If they are mutually exclusive, (env=prod vs env=staging) precedence is irrelevant.
58
+ - If you try to generate the configuration and 2 applicable overrides define a value for
59
+ the same key, an error will be raised (e.g. env=staging and region=eu). In that case,
60
+ you should add dimensions to either override to make them mutually exclusive or make
61
+ one more specific than the other.
62
+
63
+ Note that it's not a problem if incompatible overrides exist in your configuration, as
64
+ long as they are not both applicable in the same call.
57
65
 
58
66
  > [!Note]
59
67
  > Defining a list as the value of one or more conditions in an override
@@ -61,14 +69,11 @@ specific to more specific, each one overriding the values of the previous ones:
61
69
 
62
70
  ### The configuration itself
63
71
 
64
- Under the layer of `dimensions/default/override/mapping` system, what you actually define
65
- in the configuration is completely up to you. That said, only nested
72
+ Under the layer of `dimensions/default/override/mapping` system, what you actually
73
+ define in the configuration is completely up to you. That said, only nested
66
74
  "dictionnaries"/"objects"/"tables"/"mapping" (those are all the same things in
67
- Python/JS/Toml lingo) will be merged between the default and the overrides, while
68
- arrays will just replace one another. See `Arrays` below.
69
-
70
- In the generated configuration, the dimensions of the output will appear in the generated
71
- object as an object under the `dimensions` key.
75
+ Python/JS/Toml lingo) will be merged between the default and the applicable overrides,
76
+ while arrays will just replace one another. See `Arrays` below.
72
77
 
73
78
  ### Arrays
74
79
 
@@ -172,8 +177,9 @@ container.image_name = "my-image-backend"
172
177
  container.port = 8080
173
178
 
174
179
  [[override]]
175
- name = "service-dev"
180
+ when.service = "backend"
176
181
  when.environment = "dev"
182
+ name = "service-dev"
177
183
  container.env.DEBUG = true
178
184
 
179
185
  [[override]]
@@ -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
  [
@@ -64,90 +31,6 @@ def test_merge_configs__dicts_error():
64
31
  combiner.merge_configs({"a": 1}, {"a": {"b": 2}})
65
32
 
66
33
 
67
- @pytest.mark.parametrize(
68
- "mapping, expected",
69
- [
70
- pytest.param(
71
- {"env": "dev"},
72
- {
73
- "a": 1,
74
- "b": 2,
75
- "c": 3,
76
- "d": {"e": {"h": {"i": {"j": 4}}}},
77
- "g": 6,
78
- },
79
- id="no_matches",
80
- ),
81
- pytest.param(
82
- {"env": "prod"},
83
- {
84
- "a": 10,
85
- "b": 2,
86
- "c": 30,
87
- "d": {"e": {"h": {"i": {"j": 40}}}},
88
- "g": 60,
89
- },
90
- id="single_match",
91
- ),
92
- pytest.param(
93
- {"env": "staging"},
94
- {
95
- "a": 1,
96
- "b": 200,
97
- "c": 300,
98
- "d": {"e": {"h": {"i": {"j": 400}}}},
99
- "f": 500,
100
- "g": 6,
101
- },
102
- id="dont_override_if_match_is_more_specific",
103
- ),
104
- ],
105
- )
106
- def __full_chain(mapping: dict, expected: dict[str, int]):
107
- default = {
108
- "a": 1,
109
- "b": 2,
110
- "c": 3,
111
- "d": {"e": {"h": {"i": {"j": 4}}}},
112
- "g": 6,
113
- }
114
-
115
- overrides = [
116
- combiner.Override(
117
- when={"env": ["prod"]},
118
- config={
119
- "a": 10,
120
- "c": 30,
121
- "d": {"e": {"h": {"i": {"j": 40}}}},
122
- "g": 60,
123
- },
124
- ),
125
- combiner.Override(
126
- when={"env": ["staging"]},
127
- config={
128
- "b": 200,
129
- "c": 300,
130
- "d": {"e": {"h": {"i": {"j": 400}}}},
131
- "f": 500,
132
- },
133
- ),
134
- combiner.Override(
135
- when={"env": ["staging"], "region": ["us"]},
136
- config={"f": 5000, "g": 6000},
137
- ),
138
- ]
139
-
140
- result = combiner.generate_for_mapping(
141
- config=combiner.Config(
142
- dimensions={"env": ["prod", "staging"], "region": ["us"]},
143
- default=default,
144
- overrides=overrides,
145
- ),
146
- mapping=mapping,
147
- )
148
- assert result == expected
149
-
150
-
151
34
  @pytest.mark.parametrize(
152
35
  "mapping, override, expected",
153
36
  [
@@ -192,16 +75,18 @@ def test_build_config():
192
75
  raw_config = """
193
76
  [dimensions]
194
77
  env = ["dev", "staging", "prod"]
78
+ region = ["eu"]
195
79
 
196
80
  [default]
197
81
  foo = "bar"
198
82
 
199
83
  [[override]]
200
84
  when.env = ["dev", "staging"]
85
+ when.region = ["eu"]
201
86
  foo = "baz"
202
87
 
203
88
  [[override]]
204
- when.env = "prod"
89
+ when.env = "dev"
205
90
  foo = "qux"
206
91
  """
207
92
 
@@ -209,86 +94,23 @@ def test_build_config():
209
94
  config = combiner.build_config(config_dict)
210
95
 
211
96
  assert config == combiner.Config(
212
- dimensions={"env": ["dev", "staging", "prod"]},
97
+ dimensions={"env": ["dev", "staging", "prod"], "region": ["eu"]},
213
98
  default={"foo": "bar"},
214
99
  overrides=[
100
+ # Note: The order of the overrides is important: more specific overrides
101
+ # must be listed last.
215
102
  combiner.Override(
216
- when={"env": ["dev", "staging"]},
217
- config={"foo": "baz"},
103
+ when={"env": ["dev"]},
104
+ config={"foo": "qux"},
218
105
  ),
219
106
  combiner.Override(
220
- when={"env": ["prod"]},
221
- config={"foo": "qux"},
107
+ when={"env": ["dev", "staging"], "region": ["eu"]},
108
+ config={"foo": "baz"},
222
109
  ),
223
110
  ],
224
111
  )
225
112
 
226
113
 
227
- def test_build_config__duplicate_overrides():
228
- raw_config = """
229
- [dimensions]
230
- env = ["prod"]
231
-
232
- [[override]]
233
- when.env = "prod"
234
- foo = "baz"
235
-
236
- [[override]]
237
- when.env = "prod"
238
- foo = "qux"
239
- """
240
-
241
- config = toml.loads(raw_config)
242
- with pytest.raises(exceptions.DuplicateError):
243
- combiner.build_config(config)
244
-
245
-
246
- def test_build_config__duplicate_overrides_different_vars():
247
- raw_config = """
248
- [dimensions]
249
- env = ["prod"]
250
-
251
- [[override]]
252
- when.env = "prod"
253
- foo = "baz"
254
-
255
- [[override]]
256
- when.env = "prod"
257
- baz = "qux"
258
- """
259
-
260
- config = toml.loads(raw_config)
261
- assert len(combiner.build_config(config).overrides) == 2
262
-
263
-
264
- def test_build_config__duplicate_overrides_list():
265
- raw_config = """
266
- [dimensions]
267
- env = ["prod", "dev"]
268
-
269
- [[override]]
270
- when.env = ["prod"]
271
- foo = "baz"
272
- hello = 1
273
-
274
- [[override]]
275
- when.env = ["prod", "dev"]
276
- foo = "qux"
277
- hello = 1
278
- """
279
-
280
- config = toml.loads(raw_config)
281
- with pytest.raises(exceptions.DuplicateError) as excinfo:
282
- combiner.build_config(config)
283
-
284
- # Message is a bit complex so we test it too.
285
- assert (
286
- str(excinfo.value) == "In override {'env': ['prod', 'dev']}: "
287
- "Overrides with the same dimensions cannot define the same configuration keys: "
288
- "foo, hello"
289
- )
290
-
291
-
292
114
  def test_build_config__dimension_not_found_in_override():
293
115
  raw_config = """
294
116
  [dimensions]
@@ -317,6 +139,78 @@ def test_build_config__dimension_value_not_found_in_override():
317
139
  combiner.build_config(config)
318
140
 
319
141
 
142
+ def test_extract_keys():
143
+ config = toml.loads(
144
+ """
145
+ a = 1
146
+ b.c = 1
147
+ b.d = 1
148
+ e.f.g = 1
149
+ """,
150
+ )
151
+
152
+ result = list(combiner.extract_keys(config))
153
+ assert result == [
154
+ ("a",),
155
+ ("b", "c"),
156
+ ("b", "d"),
157
+ ("e", "f", "g"),
158
+ ]
159
+
160
+
161
+ @pytest.mark.parametrize(
162
+ "a, b, expected",
163
+ [
164
+ pytest.param(
165
+ {"env": ["dev"], "region": ["eu"]},
166
+ {"env": ["dev"]},
167
+ True,
168
+ id="subset1",
169
+ ),
170
+ pytest.param(
171
+ {"env": ["dev"]},
172
+ {"env": ["dev"], "region": ["eu"]},
173
+ True,
174
+ id="subset2",
175
+ ),
176
+ pytest.param(
177
+ {"env": ["prod"], "region": ["eu"]},
178
+ {"env": ["dev"]},
179
+ True,
180
+ id="subset3",
181
+ ),
182
+ pytest.param(
183
+ {"env": ["dev"]},
184
+ {"env": ["prod"], "region": ["eu"]},
185
+ True,
186
+ id="subset4",
187
+ ),
188
+ pytest.param({"env": ["dev"]}, {"region": ["eu"]}, False, id="disjoint"),
189
+ pytest.param(
190
+ {"env": ["dev"], "service": ["frontend"]},
191
+ {"region": ["eu"], "service": ["frontend"]},
192
+ False,
193
+ id="overlap",
194
+ ),
195
+ pytest.param({"env": ["dev"]}, {"env": ["dev"]}, False, id="same_keys1"),
196
+ pytest.param(
197
+ {"env": ["dev", "prod"]}, {"env": ["dev"]}, False, id="same_keys1"
198
+ ),
199
+ pytest.param(
200
+ {"env": ["prod"]}, {"env": ["dev"]}, True, id="same_keys_disjoint"
201
+ ),
202
+ pytest.param(
203
+ {"env": ["prod", "staging"]},
204
+ {"env": ["dev", "sandbox"]},
205
+ True,
206
+ id="multiple_keys_disjoint",
207
+ ),
208
+ ],
209
+ )
210
+ def test_are_conditions_compatible(a, b, expected):
211
+ assert combiner.are_conditions_compatible(a, b) == expected
212
+
213
+
320
214
  @pytest.mark.parametrize(
321
215
  "mapping, expected",
322
216
  [
@@ -325,23 +219,23 @@ def test_build_config__dimension_value_not_found_in_override():
325
219
  {"foo": "bar"},
326
220
  ),
327
221
  (
328
- {"env": "dev"},
222
+ {"env": "staging"},
329
223
  {"foo": "baz"},
330
224
  ),
331
225
  ],
332
226
  )
333
- def test_generate_for_mapping__full_chain(mapping, expected):
227
+ def test_generate_for_mapping__simple_case(mapping, expected):
334
228
  config = combiner.build_config(
335
229
  toml.loads(
336
230
  """
337
231
  [dimensions]
338
- env = ["prod", "dev"]
232
+ env = ["prod", "staging"]
339
233
 
340
234
  [default]
341
235
  foo = "bar"
342
236
 
343
237
  [[override]]
344
- when.env = "dev"
238
+ when.env = "staging"
345
239
  foo = "baz"
346
240
  """,
347
241
  )
@@ -353,43 +247,151 @@ def test_generate_for_mapping__full_chain(mapping, expected):
353
247
  assert result == expected
354
248
 
355
249
 
356
- def test_extract_keys():
357
- config = toml.loads(
358
- """
359
- a = 1
360
- b.c = 1
361
- b.d = 1
362
- e.f.g = 1
363
- """,
364
- )
250
+ @pytest.mark.parametrize(
251
+ "mapping, expected",
252
+ [
253
+ pytest.param(
254
+ {"env": "dev"},
255
+ {
256
+ "a": 1,
257
+ "b": 2,
258
+ "c": 3,
259
+ "d": {"e": {"h": {"i": {"j": 4}}}},
260
+ "g": 6,
261
+ },
262
+ id="no_matches",
263
+ ),
264
+ pytest.param(
265
+ {"env": "prod"},
266
+ {
267
+ "a": 10,
268
+ "b": 2,
269
+ "c": 30,
270
+ "d": {"e": {"h": {"i": {"j": 40}}}},
271
+ "g": 60,
272
+ },
273
+ id="single_match",
274
+ ),
275
+ pytest.param(
276
+ {"env": "staging"},
277
+ {
278
+ "a": 1,
279
+ "b": 200,
280
+ "c": 300,
281
+ "d": {"e": {"h": {"i": {"j": 400}}}},
282
+ "f": 500,
283
+ "g": 6,
284
+ },
285
+ id="dont_override_if_match_is_more_specific",
286
+ ),
287
+ ],
288
+ )
289
+ def test_generate_for_mapping__complex_case(mapping: dict, expected: dict[str, int]):
290
+ default = {
291
+ "a": 1,
292
+ "b": 2,
293
+ "c": 3,
294
+ "d": {"e": {"h": {"i": {"j": 4}}}},
295
+ "g": 6,
296
+ }
365
297
 
366
- result = list(combiner.extract_keys(config))
367
- assert result == [
368
- ("a",),
369
- ("b", "c"),
370
- ("b", "d"),
371
- ("e", "f", "g"),
298
+ overrides = [
299
+ combiner.Override(
300
+ when={"env": ["prod"]},
301
+ config={
302
+ "a": 10,
303
+ "c": 30,
304
+ "d": {"e": {"h": {"i": {"j": 40}}}},
305
+ "g": 60,
306
+ },
307
+ ),
308
+ combiner.Override(
309
+ when={"env": ["staging"]},
310
+ config={
311
+ "b": 200,
312
+ "c": 300,
313
+ "d": {"e": {"h": {"i": {"j": 400}}}},
314
+ "f": 500,
315
+ },
316
+ ),
317
+ combiner.Override(
318
+ when={"env": ["staging"], "region": ["us"]},
319
+ config={"f": 5000, "g": 6000},
320
+ ),
372
321
  ]
322
+ config = combiner.Config(
323
+ dimensions={"env": ["prod", "staging"], "region": ["us"]},
324
+ default=default,
325
+ overrides=overrides,
326
+ )
373
327
 
328
+ result = combiner.generate_for_mapping(config=config, mapping=mapping)
329
+ assert result == expected
374
330
 
375
- def test_extract_definitions():
376
- result = list(
377
- combiner.extract_conditions_and_keys(
378
- when={"env": ["dev", "staging"], "region": ["eu", "us"]},
379
- config={
380
- "a": 1,
381
- "b.c.d": 4,
382
- },
383
- )
331
+
332
+ def test_generate_for_mapping__duplicate_overrides():
333
+ raw_config = """
334
+ [dimensions]
335
+ env = ["prod"]
336
+
337
+ [[override]]
338
+ when.env = "prod"
339
+ foo = "baz"
340
+
341
+ [[override]]
342
+ when.env = "prod"
343
+ foo = "qux"
344
+ """
345
+
346
+ config = combiner.build_config(toml.loads(raw_config))
347
+ with pytest.raises(exceptions.IncompatibleOverrides):
348
+ combiner.generate_for_mapping(config=config, mapping={"env": "prod"})
349
+
350
+
351
+ def test_generate_for_mapping__duplicate_overrides_different_vars():
352
+ raw_config = """
353
+ [dimensions]
354
+ env = ["prod"]
355
+
356
+ [[override]]
357
+ when.env = "prod"
358
+ foo = "baz"
359
+
360
+ [[override]]
361
+ when.env = "prod"
362
+ baz = "qux"
363
+ """
364
+
365
+ config = combiner.build_config(toml.loads(raw_config))
366
+ assert combiner.generate_for_mapping(config=config, mapping={"env": "prod"}) == {
367
+ "foo": "baz",
368
+ "baz": "qux",
369
+ }
370
+
371
+
372
+ def test_generate_for_mapping__duplicate_overrides_list():
373
+ raw_config = """
374
+ [dimensions]
375
+ env = ["prod", "dev"]
376
+
377
+ [[override]]
378
+ when.env = ["prod"]
379
+ hello.world = 1
380
+
381
+ [[override]]
382
+ when.env = ["prod", "dev"]
383
+ hello.world = 2
384
+ """
385
+
386
+ config = combiner.build_config(toml.loads(raw_config))
387
+ with pytest.raises(exceptions.IncompatibleOverrides) as excinfo:
388
+ combiner.generate_for_mapping(config=config, mapping={"env": "prod"})
389
+
390
+ # Message is a bit complex so we test it too.
391
+ assert (
392
+ str(excinfo.value)
393
+ == "In override {'env': ['prod', 'dev']}: Overrides defining the same "
394
+ "configuration keys must be included in one another or mutually exclusive.\n"
395
+ "Key defined multiple times: hello.world\n"
396
+ "Other override: {'env': ['prod']}"
384
397
  )
385
- print(result)
386
- assert result == [
387
- ((("env", "dev"), ("region", "eu")), "a"),
388
- ((("env", "dev"), ("region", "us")), "a"),
389
- ((("env", "staging"), ("region", "eu")), "a"),
390
- ((("env", "staging"), ("region", "us")), "a"),
391
- ((("env", "dev"), ("region", "eu")), "b.c.d"),
392
- ((("env", "dev"), ("region", "us")), "b.c.d"),
393
- ((("env", "staging"), ("region", "eu")), "b.c.d"),
394
- ((("env", "staging"), ("region", "us")), "b.c.d"),
395
- ]
@@ -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,9 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import copy
4
4
  import dataclasses
5
- import itertools
6
5
  from collections.abc import Iterable, Mapping, Sequence
7
- from functools import partial
8
6
  from typing import Any, TypeVar
9
7
 
10
8
  from . import exceptions
@@ -60,48 +58,6 @@ def clean_dimensions_dict(
60
58
  return result
61
59
 
62
60
 
63
- def override_sort_key(
64
- override: Override, dimensions: dict[str, list[str]]
65
- ) -> tuple[int, ...]:
66
- """
67
- We sort overrides before applying them, and they are applied in the order of the
68
- sorted list, each override replacing the common values of the previous overrides.
69
-
70
- override_sort_key defines the sort key for overrides that ensures less specific
71
- overrides come first:
72
- - Overrides with fewer dimensions come first (will be overridden
73
- by more specific ones)
74
- - If two overrides have the same number of dimensions but define different
75
- dimensions, we sort by the definition order of the dimensions.
76
-
77
- Example:
78
- dimensions = {"env": ["dev", "prod"], "region": ["us", "eu"]}
79
-
80
- - Override with {"env": "dev"} comes before override with
81
- {"env": "dev", "region": "us"} (less specific)
82
- - Override with {"env": "dev"} comes before override with {"region": "us"} ("env"
83
- is defined before "region" in the dimensions list)
84
-
85
- Parameters:
86
- -----------
87
- override: An Override object that defines the condition when it applies
88
- (override.when)
89
- dimensions: The dict of all existing dimensions and their values, in order of
90
- definition
91
-
92
- Returns:
93
- --------
94
- A tuple that supports comparisons. Less specific Overrides should return smaller
95
- values and vice versa.
96
- """
97
- result = [len(override.when)]
98
- for i, dimension in enumerate(dimensions):
99
- if dimension in override.when:
100
- result.append(i)
101
-
102
- return tuple(result)
103
-
104
-
105
61
  T = TypeVar("T", dict, list, str, int, float, bool)
106
62
 
107
63
 
@@ -136,21 +92,27 @@ def extract_keys(config: Any) -> Iterable[tuple[str, ...]]:
136
92
  yield tuple()
137
93
 
138
94
 
139
- def extract_conditions_and_keys(
140
- when: dict[str, list[str]], config: dict[str, Any]
141
- ) -> Iterable[tuple[Any, ...]]:
95
+ def are_conditions_compatible(
96
+ a: Mapping[str, list[str]], b: Mapping[str, list[str]], /
97
+ ) -> bool:
142
98
  """
143
- Extract the definitions from an override.
99
+ `a` and `b` are dictionaries representing override conditions (`when`). Return
100
+ `True` if the conditions represented by `a` are compatible with `b`. Conditions are
101
+ compatible if one is stricly more specific than the other or if they're mutually
102
+ exclusive.
144
103
  """
145
- when_definitions = []
146
- for key, values in when.items():
147
- when_definitions.append([(key, value) for value in values])
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
148
111
 
149
- when_combined_definitions = list(itertools.product(*when_definitions))
150
- config_keys = extract_keys(config)
151
- for config_key in config_keys:
152
- for when_definition in when_combined_definitions:
153
- yield (when_definition, *config_key)
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
154
116
 
155
117
 
156
118
  def build_config(config: dict[str, Any]) -> Config:
@@ -161,9 +123,6 @@ def build_config(config: dict[str, Any]) -> Config:
161
123
  # Parse template
162
124
  default = config.pop("default", {})
163
125
 
164
- # The rule is: the same exact set of conditions cannot be used twice to define
165
- # the same values (on the same or different overrides)
166
- seen_conditions_and_keys = set()
167
126
  overrides = []
168
127
  for override in config.pop("override", []):
169
128
  try:
@@ -176,22 +135,10 @@ def build_config(config: dict[str, Any]) -> Config:
176
135
  type="override",
177
136
  )
178
137
 
179
- conditions_and_keys = set(
180
- extract_conditions_and_keys(when=when, config=override)
181
- )
182
- if duplicates := (conditions_and_keys & seen_conditions_and_keys):
183
- duplicate_str = ", ".join(sorted(key for *_, key in duplicates))
184
- raise exceptions.DuplicateError(id=when, details=duplicate_str)
185
-
186
- seen_conditions_and_keys |= conditions_and_keys
187
-
188
138
  overrides.append(Override(when=when, config=override))
189
139
 
190
140
  # Sort overrides by increasing specificity
191
- overrides = sorted(
192
- overrides,
193
- key=partial(override_sort_key, dimensions=dimensions),
194
- )
141
+ overrides = sorted(overrides, key=lambda override: len(override.when))
195
142
 
196
143
  return Config(
197
144
  dimensions=dimensions,
@@ -219,11 +166,28 @@ def generate_for_mapping(
219
166
  mapping: Mapping[str, str],
220
167
  ) -> Mapping[str, Any]:
221
168
  result = copy.deepcopy(config.default)
169
+ keys_to_conditions: dict[tuple[str, ...], list[Mapping[str, list[str]]]] = {}
222
170
  # Apply each matching override
223
171
  for override in config.overrides:
224
172
  # Check if all dimension values in the override match
225
173
 
226
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.IncompatibleOverrides(
184
+ id=override.when,
185
+ key=".".join(key),
186
+ other_override=previous_condition,
187
+ )
188
+
189
+ keys_to_conditions[key].append(override.when)
190
+
227
191
  result = merge_configs(result, override.config)
228
192
 
229
193
  return result
@@ -19,8 +19,8 @@ class TomlEncodeError(TomlCombineError):
19
19
  """Error while encoding configuration file."""
20
20
 
21
21
 
22
- class DuplicateError(TomlCombineError):
23
- """In override {id}: Overrides with the same dimensions cannot define the same configuration keys: {details}"""
22
+ class IncompatibleOverrides(TomlCombineError):
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):
File without changes