toml-combine 0.1.4__py3-none-any.whl

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.
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import pathlib
4
+ from typing import Any, cast, overload
5
+
6
+ from . import combiner, toml
7
+
8
+
9
+ # Provide already parsed config
10
+ @overload
11
+ def combine(
12
+ *, config: dict[str, Any], **filters: str | list[str]
13
+ ) -> dict[str, Any]: ...
14
+ # Provide toml config content
15
+ @overload
16
+ def combine(*, config: str, **filters: str | list[str]) -> dict[str, Any]: ...
17
+ # Provide toml config file path
18
+ @overload
19
+ def combine(
20
+ *, config_file: str | pathlib.Path, **filters: str | list[str]
21
+ ) -> dict[str, Any]: ...
22
+
23
+
24
+ def combine(*, config=None, config_file=None, **filters):
25
+ """
26
+ Generate outputs of configurations based on the provided TOML
27
+ configuration.
28
+
29
+ Args:
30
+ config: The TOML configuration as a string or an already parsed dictionary.
31
+ OR:
32
+ config_file: The path to the TOML configuration file.
33
+ **filters: Filters to apply to the combined configuration
34
+ (dimension="value" or dimension=["list", "of", "values"]).
35
+
36
+ Returns:
37
+ dict[str, Any]: The combined configuration ({"output_id": {...}}).
38
+ """
39
+ if (config is None) is (config_file is None):
40
+ raise ValueError("Either 'config' or 'config_file' must be provided.")
41
+
42
+ if isinstance(config, dict):
43
+ dict_config = config
44
+ else:
45
+ if config_file:
46
+ config_string = pathlib.Path(config_file).read_text()
47
+ else:
48
+ config = cast(str, config)
49
+ config_string = config
50
+
51
+ dict_config = toml.loads(config_string)
52
+
53
+ config_obj = combiner.build_config(dict_config)
54
+
55
+ return combiner.generate_outputs(config_obj, **filters)
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from . import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli.run_cli()
toml_combine/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import pathlib
6
+ import sys
7
+ from collections.abc import Mapping
8
+
9
+ from . import combiner, exceptions, toml
10
+
11
+
12
+ def get_argument_parser(
13
+ dimensions: Mapping[str, list[str]] | None,
14
+ ) -> argparse.ArgumentParser:
15
+ """Get the command-line argument parser."""
16
+ arg_parser = argparse.ArgumentParser(
17
+ description="Create combined configurations from a TOML file",
18
+ add_help=(dimensions is not None),
19
+ )
20
+ arg_parser.add_argument(
21
+ "config",
22
+ type=pathlib.Path,
23
+ help="Path to the TOML configuration file",
24
+ )
25
+ if dimensions:
26
+ group = arg_parser.add_argument_group(
27
+ "dimensions",
28
+ "Filter the generated outputs by dimensions",
29
+ )
30
+
31
+ for name, values in dimensions.items():
32
+ group.add_argument(
33
+ f"--{name}", choices=values, help=f"Limit to given {name}"
34
+ )
35
+
36
+ return arg_parser
37
+
38
+
39
+ def cli(argv) -> int:
40
+ """Main entry point."""
41
+
42
+ # Parse the config file argument to get the dimensions
43
+ arg_parser = get_argument_parser(dimensions=None)
44
+ args, _ = arg_parser.parse_known_args(argv)
45
+
46
+ try:
47
+ dict_config = toml.loads(args.config.read_text())
48
+ except FileNotFoundError:
49
+ print(f"Configuration file not found: {args.config}", file=sys.stderr)
50
+ return 1
51
+ except exceptions.TomlDecodeError as exc:
52
+ print(exc, file=sys.stderr)
53
+ return 1
54
+
55
+ try:
56
+ config = combiner.build_config(dict_config)
57
+ except exceptions.TomlCombineError as exc:
58
+ print(exc, file=sys.stderr)
59
+ return 1
60
+
61
+ # Parse all arguments
62
+ arg_parser = get_argument_parser(dimensions=config.dimensions)
63
+ args = arg_parser.parse_args(argv)
64
+
65
+ dimensions_filter = {
66
+ key: value
67
+ for key, value in vars(args).items()
68
+ if key in config.dimensions and value
69
+ }
70
+ # Generate final configurations for each output
71
+ try:
72
+ result = combiner.generate_outputs(config=config, **dimensions_filter)
73
+ except exceptions.TomlCombineError as exc:
74
+ print(exc, file=sys.stderr)
75
+ return 1
76
+
77
+ if not result:
78
+ print("No outputs found", file=sys.stderr)
79
+
80
+ print(json.dumps(result, indent=2))
81
+
82
+ return 0
83
+
84
+
85
+ def run_cli():
86
+ sys.exit(cli(sys.argv[1:]))
@@ -0,0 +1,249 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import dataclasses
5
+ import itertools
6
+ from collections.abc import Mapping, Sequence
7
+ from functools import partial
8
+ from typing import Any
9
+
10
+ from . import exceptions
11
+
12
+
13
+ @dataclasses.dataclass()
14
+ class Output:
15
+ dimensions: Mapping[str, str]
16
+
17
+ @property
18
+ def id(self) -> str:
19
+ return f"{'-'.join(self.dimensions.values())}"
20
+
21
+ def __str__(self) -> str:
22
+ return f"Output(id={self.id})"
23
+
24
+
25
+ @dataclasses.dataclass()
26
+ class Override:
27
+ when: Mapping[str, str | list[str]]
28
+ config: Mapping[str, Any]
29
+
30
+ def __str__(self) -> str:
31
+ return f"Override({self.when})"
32
+
33
+
34
+ @dataclasses.dataclass()
35
+ class Config:
36
+ dimensions: Mapping[str, list[str]]
37
+ outputs: list[Output]
38
+ default: Mapping[str, Any]
39
+ overrides: Sequence[Override]
40
+
41
+
42
+ def wrap_in_list(value: str | list[str]) -> list[str]:
43
+ """
44
+ Wrap a string in a list if it's not already a list.
45
+ """
46
+ if isinstance(value, str):
47
+ return [value]
48
+ return value
49
+
50
+
51
+ def clean_dimensions_dict(
52
+ to_sort: Mapping[str, str | list[str]], clean: dict[str, list[str]], type: str
53
+ ) -> dict[str, str]:
54
+ """
55
+ Recreate a dictionary of dimension values with the same order as the
56
+ dimensions list.
57
+ """
58
+ result = {}
59
+ remaining = dict(to_sort)
60
+
61
+ for dimension, valid_values in clean.items():
62
+ valid_values = set(valid_values)
63
+ if dimension not in to_sort:
64
+ continue
65
+
66
+ original_value = remaining.pop(dimension)
67
+ values = set(wrap_in_list(original_value))
68
+ if invalid_values := values - valid_values:
69
+ raise exceptions.DimensionValueNotFound(
70
+ type=type,
71
+ id=to_sort,
72
+ dimension=dimension,
73
+ value=", ".join(invalid_values),
74
+ )
75
+ result[dimension] = original_value
76
+
77
+ if remaining:
78
+ raise exceptions.DimensionNotFound(
79
+ type=type,
80
+ id=to_sort,
81
+ dimension=", ".join(to_sort),
82
+ )
83
+
84
+ return result
85
+
86
+
87
+ def override_sort_key(
88
+ override: Override, dimensions: dict[str, list[str]]
89
+ ) -> tuple[int, ...]:
90
+ """
91
+ We sort overrides before applying them, and they are applied in the order of the
92
+ sorted list, each override replacing the common values of the previous overrides.
93
+
94
+ override_sort_key defines the sort key for overrides that ensures less specific
95
+ overrides come first:
96
+ - Overrides with fewer dimensions come first (will be overridden
97
+ by more specific ones)
98
+ - If two overrides have the same number of dimensions but define different
99
+ dimensions, we sort by the definition order of the dimensions.
100
+
101
+ Example:
102
+ dimensions = {"env": ["dev", "prod"], "region": ["us", "eu"]}
103
+
104
+ - Override with {"env": "dev"} comes before override with
105
+ {"env": "dev", "region": "us"} (less specific)
106
+ - Override with {"env": "dev"} comes before override with {"region": "us"} ("env"
107
+ is defined before "region" in the dimensions list)
108
+ """
109
+ result = [len(override.when)]
110
+ for i, dimension in enumerate(dimensions):
111
+ if dimension in override.when:
112
+ result.append(i)
113
+
114
+ return tuple(result)
115
+
116
+
117
+ def merge_configs(a: Any, b: Any, /) -> Any:
118
+ """
119
+ Recursively merge two configuration dictionaries, with b taking precedence.
120
+ """
121
+ if isinstance(a, dict) != isinstance(b, dict):
122
+ raise ValueError(f"Cannot merge {type(a)} with {type(b)}")
123
+
124
+ if not isinstance(a, dict):
125
+ return b
126
+
127
+ result = a.copy()
128
+ for key, b_value in b.items():
129
+ if a_value := a.get(key):
130
+ result[key] = merge_configs(a_value, b_value)
131
+ else:
132
+ result[key] = b_value
133
+ return result
134
+
135
+
136
+ def build_config(config: dict[str, Any]) -> Config:
137
+ # Parse dimensions
138
+ dimensions = config.pop("dimensions")
139
+
140
+ # Parse template
141
+ default = config.pop("default", {})
142
+
143
+ seen_conditions = set()
144
+ overrides = []
145
+ for override in config.pop("override", []):
146
+ try:
147
+ when = override.pop("when")
148
+ except KeyError:
149
+ raise exceptions.MissingOverrideCondition(id=override)
150
+
151
+ conditions = tuple((k, tuple(wrap_in_list(v))) for k, v in when.items())
152
+ if conditions in seen_conditions:
153
+ raise exceptions.DuplicateError(type="override", id=when)
154
+
155
+ seen_conditions.add(conditions)
156
+
157
+ overrides.append(
158
+ Override(
159
+ when=clean_dimensions_dict(
160
+ to_sort=when, clean=dimensions, type="override"
161
+ ),
162
+ config=override,
163
+ )
164
+ )
165
+ # Sort overrides by increasing specificity
166
+ overrides = sorted(
167
+ overrides,
168
+ key=partial(override_sort_key, dimensions=dimensions),
169
+ )
170
+
171
+ outputs = []
172
+ seen_conditions = set()
173
+
174
+ for output in config.pop("output", []):
175
+ for key in output:
176
+ output[key] = wrap_in_list(output[key])
177
+
178
+ for cartesian_product in itertools.product(*output.values()):
179
+ # Create a dictionary with the same keys as when
180
+ single_output = dict(zip(output.keys(), cartesian_product))
181
+
182
+ conditions = tuple(single_output.items())
183
+ if conditions in seen_conditions:
184
+ raise exceptions.DuplicateError(type="output", id=output.id)
185
+ seen_conditions.add(conditions)
186
+
187
+ outputs.append(
188
+ Output(
189
+ dimensions=clean_dimensions_dict(
190
+ single_output, dimensions, type="output"
191
+ ),
192
+ )
193
+ )
194
+
195
+ return Config(
196
+ dimensions=dimensions,
197
+ outputs=outputs,
198
+ default=default,
199
+ overrides=overrides,
200
+ )
201
+
202
+
203
+ def generate_output(
204
+ default: Mapping[str, Any], overrides: Sequence[Override], output: Output
205
+ ) -> dict[str, Any]:
206
+ result = copy.deepcopy(default)
207
+ # Apply each matching override
208
+ for override in overrides:
209
+ # Check if all dimension values in the override match
210
+ if all(
211
+ override.when.get(dim) == output.dimensions.get(dim)
212
+ for dim in override.when.keys()
213
+ ):
214
+ result = merge_configs(result, override.config)
215
+
216
+ return {"dimensions": output.dimensions, **result}
217
+
218
+
219
+ def generate_outputs(config: Config, **filter: str | list[str]) -> dict[str, Any]:
220
+ result = {}
221
+ filter_with_lists: dict[str, list[str]] = {}
222
+
223
+ for key, value in list(filter.items()):
224
+ if key not in config.dimensions:
225
+ raise exceptions.DimensionNotFound(type="arguments", id="", dimension=key)
226
+
227
+ value = wrap_in_list(value)
228
+
229
+ if set(value) - set(config.dimensions[key]):
230
+ raise exceptions.DimensionValueNotFound(
231
+ type="arguments",
232
+ id="",
233
+ dimension=key,
234
+ value=", ".join(set(value) - set(config.dimensions[key])),
235
+ )
236
+ filter_with_lists[key] = value
237
+
238
+ for output in config.outputs:
239
+ if all(
240
+ output.dimensions.get(key) in value
241
+ for key, value in filter_with_lists.items()
242
+ ):
243
+ result[output.id] = generate_output(
244
+ default=config.default,
245
+ overrides=config.overrides,
246
+ output=output,
247
+ )
248
+
249
+ return result
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class TomlCombineError(Exception):
5
+ """There was an error in the toml-combine library."""
6
+
7
+ def __init__(self, message: str = "", **kwargs) -> None:
8
+ message = message or type(self).__doc__ or ""
9
+ message = message.format(**kwargs)
10
+
11
+ super().__init__(message)
12
+
13
+
14
+ class TomlDecodeError(TomlCombineError):
15
+ """Error while decoding configuration file."""
16
+
17
+
18
+ class DuplicateError(TomlCombineError):
19
+ """In {type} {id}: Cannot have multiple {type}s with the same dimensions."""
20
+
21
+
22
+ class DimensionNotFound(TomlCombineError):
23
+ """In {type} {id}: Dimension {dimension} not found."""
24
+
25
+
26
+ class DimensionValueNotFound(TomlCombineError):
27
+ """In {type} {id}: Value {value} for dimension {dimension} not found."""
28
+
29
+
30
+ class MissingOverrideCondition(TomlCombineError):
31
+ """In override {id}: Missing 'when' key in override configuration"""
toml_combine/toml.py ADDED
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import tomli
6
+
7
+ from . import exceptions
8
+
9
+
10
+ def loads(raw_config: str) -> dict[str, Any]:
11
+ try:
12
+ return tomli.loads(raw_config)
13
+ except tomli.TOMLDecodeError as e:
14
+ raise exceptions.TomlDecodeError(
15
+ message="Syntax error in TOML configuration file",
16
+ context=str(e),
17
+ ) from e
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.4
2
+ Name: toml-combine
3
+ Version: 0.1.4
4
+ Summary: A tool for combining complex configurations in TOML format.
5
+ Author-email: Joachim Jablon <ewjoachim@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Requires-Python: >=3.9
17
+ Requires-Dist: tomli>=2.2.1
18
+ Description-Content-Type: text/markdown
19
+
20
+ # Toml-combine
21
+
22
+ `toml-combine` is a Python lib and CLI-tool that reads a toml configuration defining
23
+ a default configuration and overrides with, and applies those overrides to get
24
+ final configurations. Say: you have multiple services, and environments, and you
25
+ want to describe them all without repeating the parts that are common to everyone.
26
+
27
+ ## Concepts
28
+
29
+ ### The config file
30
+
31
+ The configuration file is usually a TOML file. Here's a small example:
32
+
33
+ ```toml
34
+ [dimensions]
35
+ environment = ["production", "staging"]
36
+
37
+ [[output]]
38
+ environment = "production"
39
+
40
+ [[output]]
41
+ environment = "staging"
42
+
43
+ [default]
44
+ name = "my-service"
45
+ registry = "gcr.io/my-project/"
46
+ container.image_name = "my-image"
47
+ container.port = 8080
48
+ service_account = "my-service-account"
49
+
50
+ [[override]]
51
+ when.environment = "staging"
52
+ service_account = "my-staging-service-account"
53
+ ```
54
+
55
+ ### Dimensions
56
+
57
+ Consider all the configurations you want to generate. Each one differs from the others.
58
+ Dimensions lets you describe the main "thing" that makes manifests differents, e.g.:
59
+ `environment` might be `staging` or `production`, region might be `eu` or `us`, and
60
+ service might be `frontend` or `backend`. Some combinations of dimensions might not
61
+ exists, for example, maybe there's no `staging` in `eu`.
62
+
63
+ ### Outputs
64
+
65
+ Create a `output` for each configuration you want to generate, and specify the
66
+ dimensions relevant for this output. It's ok to omit some dimensions when they're not
67
+ used for a given output.
68
+
69
+ > [!Note]
70
+ > Defining a list as the value of one or more dimensions in a output
71
+ > is a shorthand for defining all combinations of dimensions
72
+
73
+ ### Default
74
+
75
+ The common configuration to start from, before we start overlaying overrides on top.
76
+
77
+ ### Overrides
78
+
79
+ Overrides define a set of condition where they apply (`when.<dimension> =
80
+ "<value>"`) and the values that are overriden. Overrides are applied in order from less
81
+ specific to more specific, each one overriding the values of the previous ones:
82
+
83
+ - If an override contains conditions on more dimensions than another one, it's applied
84
+ later
85
+ - In case 2 overrides contain the same number of dimensions and they're a disjoint set,
86
+ then it depends on how the dimensions are defined at the top of the file: dimensions
87
+ defined last have a greater priority
88
+
89
+ > [!Note]
90
+ > Defining a list as the value of one or more conditions in an override
91
+ > means that the override will apply to any of the dimension values of the list
92
+
93
+ ### The configuration itself
94
+
95
+ Under the layer of `dimensions/output/default/override` system, what you actually define
96
+ in the configuration is completely up to you. That said, only nested
97
+ "dictionnaries"/"objects"/"tables"/"mapping" (those are all the same things in
98
+ Python/JS/Toml lingo) will be merged between the default and the overrides, while
99
+ arrays will just replace one another. See `Arrays` below.
100
+
101
+ In the generated configuration, the dimensions of the output will appear in the generated
102
+ object as an object under the `dimensions` key.
103
+
104
+ ### Arrays
105
+
106
+ Let's look at an example:
107
+
108
+ ```toml
109
+ [dimensions]
110
+ environment = ["production", "staging"]
111
+
112
+ [[output]]
113
+ environment = ["production", "staging"]
114
+
115
+ [default]
116
+ fruits = [{name="apple", color="red"}]
117
+
118
+ [[override]]
119
+ when.environment = "staging"
120
+ fruits = [{name="orange", color="orange"}]
121
+ ```
122
+
123
+ In this example, on staging, `fruits` is `[{name="orange", color="orange"}]` and not `[{name="apple", color="red"}, {name="orange", color="orange"}]`.
124
+ The only way to get multiple values to be merged is if they are tables: you'll need
125
+ to chose an element to become the key:
126
+
127
+ ```toml
128
+ [dimensions]
129
+ environment = ["production", "staging"]
130
+
131
+ [[output]]
132
+ environment = ["production", "staging"]
133
+
134
+ [default]
135
+ fruits.apple.color = "red"
136
+
137
+ [[override]]
138
+ when.environment = "staging"
139
+ fruits.orange.color = "orange"
140
+ ```
141
+
142
+ In this example, on staging, `fruits` is `{apple={color="red"}, orange={color="orange"}}`.
143
+
144
+ This example is simple because `name` is a natural choice for the key. In some cases,
145
+ the choice is less natural, but you can always decide to name the elements of your
146
+ list and use that name as a key. Also, yes, you'll loose ordering.
147
+
148
+ ### A bigger example
149
+
150
+ ```toml
151
+ [dimensions]
152
+ environment = ["production", "staging", "dev"]
153
+ service = ["frontend", "backend"]
154
+
155
+ # All 4 combinations of those values will exist
156
+ [[output]]
157
+ environment = ["production", "staging"]
158
+ service = ["frontend", "backend"]
159
+
160
+ # On dev, the "service" is not defined. That's ok.
161
+ [[output]]
162
+ environment = "dev"
163
+
164
+ [default]
165
+ registry = "gcr.io/my-project/"
166
+ service_account = "my-service-account"
167
+
168
+ [[override]]
169
+ when.service = "frontend"
170
+ name = "service-frontend"
171
+ container.image_name = "my-image-frontend"
172
+
173
+ [[override]]
174
+ when.service = "backend"
175
+ name = "service-backend"
176
+ container.image_name = "my-image-backend"
177
+ container.port = 8080
178
+
179
+ [[override]]
180
+ name = "service-dev"
181
+ when.environment = "dev"
182
+ container.env.DEBUG = true
183
+ ```
184
+
185
+ ### CLI
186
+
187
+ ```console
188
+ $ toml-combine {path/to/config.toml}
189
+ ```
190
+
191
+ Generates all the outputs described by the given TOML config.
192
+
193
+ Note that you can restrict generation to some dimension values by passing
194
+ `--{dimension}={value}`
195
+
196
+ ## Lib
197
+
198
+ ```python
199
+ import toml_combine
200
+
201
+
202
+ result = toml_combine.combine(
203
+ config_file=config_file,
204
+ environment=["production", "staging"],
205
+ type="job",
206
+ job=["manage", "special-command"],
207
+ )
208
+
209
+ print(result)
210
+ ```
@@ -0,0 +1,10 @@
1
+ toml_combine/__init__.py,sha256=l7i0GkM9k7cc__wj1yqK5XjpB3IJ0jqFU63tqKMuYlY,1625
2
+ toml_combine/__main__.py,sha256=hmF8N8xX6UEApzbKTVZ-4E1HU5-rjgUkdXNLO-mF6vo,100
3
+ toml_combine/cli.py,sha256=MZrAEP4wt6f9Qn0TEXIjeLoQMlvQulFpkMciwU8GRO4,2328
4
+ toml_combine/combiner.py,sha256=98iWUnkbzDVGEkw-dXFZEt5G7io5SQzayceuTU2hRvo,7315
5
+ toml_combine/exceptions.py,sha256=SepRFDxeWQEbD88jhF5g7laZSSULthho83BpW8u9RWs,897
6
+ toml_combine/toml.py,sha256=_vCINvfJeS3gWid35Pmm3Yz4xyJ8LpKJRHL0axSU8nk,384
7
+ toml_combine-0.1.4.dist-info/METADATA,sha256=_IzI_kjsNl52wFynzgII2zXeMGnD8XqJ8Mw5FHwOLIs,6051
8
+ toml_combine-0.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ toml_combine-0.1.4.dist-info/entry_points.txt,sha256=dXUQNom54uZt_7ylEG81iNYMamYpaFo9-ItcZJU6Uzc,58
10
+ toml_combine-0.1.4.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ toml-combine = toml_combine.cli:run_cli