appsettings2 0.0.1__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Shaun Wilson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.1
2
+ Name: appsettings2
3
+ Version: 0.0.1
4
+ Summary: A python library that unifies configuration sources into a Configuration object that can be bound to complex types, or accessed directly for configuration data.
5
+ License: MIT
6
+ Author: Shaun Wilson
7
+ Author-email: mrshaunwilson@msn.com
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
14
+ Project-URL: documentation, https://github.com/wilson0x4d/appsettings2/README.md
15
+ Project-URL: homepage, https://github.com/wilson0x4d/appsettings2
16
+ Project-URL: repository, https://github.com/wilson0x4d/appsettings2.git
17
+ Description-Content-Type: text/markdown
18
+
19
+ # `appsettings2`
20
+
21
+ A python library that unifies configuration sources into a Configuration object that can be bound to complex types, or accessed directly for configuration data.
22
+
23
+ ## Why?
24
+
25
+ Because I write a lot of containerized applications where I typically supply configuration settings using a combination of Configuration Files and Environment Variables.
26
+
27
+ When I debug those applications I often need to override Confguration using Command-Line Arguments, Configuration Files, Environment Variables, or a combination thereof.
28
+
29
+ ## Usage
30
+
31
+ Construct a `ConfigurationBuilder` instance, add one or more `ConfigurationProvider` instances to it, and then use it to build a `Configuration` object containing your unified configuration data.
32
+
33
+ ```python
34
+ from appsettings2 import *
35
+ from appsettings2.providers import *
36
+
37
+ def get_configuration() -> Configuration:
38
+ return ConfigurationBuilder()\
39
+ .addProvider(JsonConfigurationProvider(f'appsettings.json'))\
40
+ .addProvider(JsonConfigurationProvider(f'appsettings.Development.json', required=False))\
41
+ .addProvider(EnvironmentConfigurationProvider())\
42
+ .build()
43
+ ```
44
+
45
+ In the above example two JSON Configuration Providers are added to the builder. The first will fail if "appsettings.json" is not found, the second will succeed whether or not the "appsettings.Development.json" exists (because `required=False`). This allows for an optional development configuration to exist on development workstations without requiring a code change.
46
+
47
+ A third Configuration Provider is also added, an `EnvironmentConfigurationProvider`, which allows configuration to be loaded from Environment Variables.
48
+
49
+ The order of providers determines precedence of configuration data. Providers added last will take precedence over providers added first. Values which are not provided by later registrations will not override values provided by earlier registrations.
50
+
51
+ Given the example above, this means that configuration data provided via Environment Variables will override configuration data provided via JSON files.
52
+
53
+ ## Accssing Configuration Data
54
+
55
+ There are two methods of directly accessing Configuration data directly. The first is by using first-class attributes in python, and the second is by using a `get(...)` method.
56
+
57
+ Consider the following code which demonstrates both methods, where all three of these statements are equivalent:
58
+
59
+ ```python
60
+ value = configuration.ConnectionStrings.SampleDb
61
+ value = configuration.get('ConnectionStrings:SampleDb')
62
+ value = configuration.get('ConnectionStrings__SampleDb')
63
+ ```
64
+
65
+ When accessing hierarchical data you can use a double-underscore delimiter or a colon delimiter, they are equivalent. Devs and Ops from different walks will likely prefer one form over the other, so both are supported.
66
+
67
+ ## Binding to Configuration Data
68
+
69
+ It's also possible to bind configuration data to complex types. While some of the implementation is currently naive, it should work well for the vast majority of use-cases. If you find your particular edge case does not work well please reach out to me and I will work with you to implement a sensible solution. Consider the following Python code:
70
+
71
+ ```python
72
+ json = """{
73
+ "ConnectionStrings": {
74
+ "SampleDb": "my_cxn_string"
75
+ },
76
+ "EnableSwagger": true,
77
+ "MaxBatchSize": 100
78
+ }"""
79
+
80
+ class ConnStrs:
81
+ """An ugly class name to demonstrate the class name does not matter."""
82
+ SampleDb:str = None
83
+
84
+ class AppSettings:
85
+ ConnectionStrings:ConnStrs = None
86
+ EnableSwagger:bool = None
87
+ MaxBatchSize:int = None
88
+
89
+ configuration = ConfigurationBuilder()\
90
+ .addProvider(JsonConfigurationProvider(json=json))
91
+ .build()
92
+
93
+ settings = AppSettings()
94
+ configuration.bind(settings)
95
+ ```
96
+
97
+ The resulting `settings` object will contain all of the configuration data properly typed according to type hints.
98
+
99
+ It is also possible to bind to a subset of a configuration, building upon the above, consider the following:
100
+
101
+ ```python
102
+ connectionStrings = configuration.bind(ConnectionStrings(), 'ConnectionStrings')
103
+ ```
104
+
105
+ ### Projecting Configuration Data as a Dictionary
106
+
107
+ Laslty, as a convenience item you can materialize a `Configuration` instance as a Dictionary. This is primarily included as a debugging aid, but, if you have some chunk of code that can consume a dictionary but can't consume a Python object (it happens) then you can get at a dictionary as follows:
108
+
109
+ ```python
110
+ config:Configuration = builder.build()
111
+ my_dict = config.toDictionary()
112
+ print(my_dict)
113
+ ```
114
+
115
+ ## Providers
116
+
117
+ ### Command-Line
118
+
119
+ The `CommandLineConfigurationProvider` allows configuration data to be provided via command-line interface. This provider will process variables in the following forms as key-value pairs:
120
+
121
+ ```
122
+ --ConnectionStrings__SampleDb 'my_connection_string'
123
+ ```
124
+
125
+ ```
126
+ 'ConnectionStrings__SampleDb=my_connection_string'
127
+ ```
128
+
129
+ ```
130
+ --ConnectionStrings__SampleDb=my_connection_string
131
+ ```
132
+
133
+ All three of these forms result in a configuration object with the following state (represented as JSON):
134
+
135
+ ```json
136
+ {
137
+ "ConnectionStrings" : {
138
+ "SampleDb": "my_connection_string"
139
+ }
140
+ }
141
+ ```
142
+
143
+ Take note that the leading dashes are stripped.
144
+
145
+ This double-underscore convention is a convention borrowed from other platforms/frameworks.
146
+
147
+ It's also possible to use a colon in lieu of a double_underscore, but this may have unintended side-effects depending on the shell or platform being used:
148
+
149
+ ```
150
+ 'ConnectionStrings:SampleDb=my_connection_string'
151
+ ```
152
+
153
+ This provider was implemented in a way that it does not interfere with libraries such as `argparse`, and should work as expected with a well-formed command-line interface.
154
+
155
+ ### Environment Variables
156
+
157
+ The `EnvironmentConfigurationProvider` consumes environment variables as key-value pairs. For example, consider these `bash` exports:
158
+
159
+ ```bash
160
+ export ConnectionStrings__SampleDb=my_cxn_string
161
+ export ConnectionStrings__AnotherDb=another_cxn_string
162
+ ```
163
+
164
+ The above will resule in a Configuration object with the following state (represented as JSON):
165
+
166
+ ```json
167
+ {
168
+ "ConnectionStrings": {
169
+ "SampleDb": "my_cxn_string",
170
+ "AnotherDb": "another_cxn_string"
171
+ }
172
+ }
173
+ ```
174
+
175
+ ### JSON
176
+
177
+ The `JsonConfigurationProvider` class allows you to load configuration data from a JSON file, JSON string, or JSON stream. Consider the following JSON:
178
+
179
+ ```json
180
+ {
181
+ "Logging": {
182
+ "Default": "Debug"
183
+ },
184
+ "ConnectionStrings": {
185
+ "SampleDb": "my_cxn_string"
186
+ },
187
+ }
188
+ ```
189
+
190
+ When loaded using `JsonConfigurationProvider` the resulting `Configuration` object will have attribute names and a structure which matches the JSON, consider the following Python code (where "example.json" contains the above JSON):
191
+
192
+ ```python
193
+ from appsettings2 import *
194
+ from appsettings2.providers import *
195
+
196
+ config = ConfigurationBuilder(normalize=True)\
197
+ .addProvider(JsonConfigurationProvider('example.json'))\
198
+ .build()
199
+
200
+ print(config.ConnectionStrings.SampleDb)
201
+ ```
202
+ Outputs: `my_cxn_string` (as expected.)
203
+
204
+ ### TOML
205
+
206
+ The `TomlConfigurationProvider` should be functionally identical to the JSON provider, with the obvious difference that it loads Configuration data from a toml file.
207
+
208
+ ### YAML
209
+
210
+ The `YamlConfigurationProvider` should be functionally identical to the JSON and TOML providers, with the obvious difference that it loads Configuration data from a yaml file.
211
+
212
+ ## A note on `normalize` parameters..
213
+
214
+ `ConfigurationBuilder` accepts a parameter `normalize:bool` which defaults to `False` causing `bind()` to behave in a case-sensitive fashion.
215
+
216
+ When set to `True` it causes the internal implementation of `Configuration` to normalize all configuration keys to uppercase, and consequently, `bind(...)` is no longer case-sensitive.
217
+
218
+ However, the resulting `Configuration` object attribute names also become normalized to upper case which may not be desired by some developers.
219
+
220
+ Probably most devs and devops working in enterprise environments will want to adopt `normalize=True` as a matter of practice. This will allow devops to conform around the use of upper-case key names in configuration systems, and allow devs to use whatever casing rules make the most sense for their projects (since `bind()` will still work as expected.) This does assume developers are creating concerete configuration types (you ARE doing that, correct?)
221
+
222
+ I would have preferred to make `normalize=True` by default, but, I suspect someone out there will want to use `Configuration` instances directly rather than create bind targets and this would have resulted in attribute names that were upper case. To illustrate, from one of the early examples in this doc would instead look like this:
223
+
224
+ ```python
225
+ value = configuration.CONNECTIONSTRINGS.SAMPLEDB # <-- this
226
+ value = configuration.get('ConnectionStrings:SampleDb')
227
+ value = configuration.get('ConnectionStrings__SampleDb')
228
+ ```
229
+
230
+ In the future I may further abstract this detail away so we get the best of both worlds, but for now there is the parameter to let you control the behavior.
231
+
232
+ ## Conclusion
233
+
234
+ If you made it this far, this library is probably what you were looking for. I'm certain there are other libraries which accomplish much of what I am attempting to accomplish here, but I wanted something i can directly support over the coming years. That, and the nearest equivalent to what I wanted fell out of active development nearly a decade ago and I just didn't feel comfortable adopting it for new work.
235
+
@@ -0,0 +1,216 @@
1
+ # `appsettings2`
2
+
3
+ A python library that unifies configuration sources into a Configuration object that can be bound to complex types, or accessed directly for configuration data.
4
+
5
+ ## Why?
6
+
7
+ Because I write a lot of containerized applications where I typically supply configuration settings using a combination of Configuration Files and Environment Variables.
8
+
9
+ When I debug those applications I often need to override Confguration using Command-Line Arguments, Configuration Files, Environment Variables, or a combination thereof.
10
+
11
+ ## Usage
12
+
13
+ Construct a `ConfigurationBuilder` instance, add one or more `ConfigurationProvider` instances to it, and then use it to build a `Configuration` object containing your unified configuration data.
14
+
15
+ ```python
16
+ from appsettings2 import *
17
+ from appsettings2.providers import *
18
+
19
+ def get_configuration() -> Configuration:
20
+ return ConfigurationBuilder()\
21
+ .addProvider(JsonConfigurationProvider(f'appsettings.json'))\
22
+ .addProvider(JsonConfigurationProvider(f'appsettings.Development.json', required=False))\
23
+ .addProvider(EnvironmentConfigurationProvider())\
24
+ .build()
25
+ ```
26
+
27
+ In the above example two JSON Configuration Providers are added to the builder. The first will fail if "appsettings.json" is not found, the second will succeed whether or not the "appsettings.Development.json" exists (because `required=False`). This allows for an optional development configuration to exist on development workstations without requiring a code change.
28
+
29
+ A third Configuration Provider is also added, an `EnvironmentConfigurationProvider`, which allows configuration to be loaded from Environment Variables.
30
+
31
+ The order of providers determines precedence of configuration data. Providers added last will take precedence over providers added first. Values which are not provided by later registrations will not override values provided by earlier registrations.
32
+
33
+ Given the example above, this means that configuration data provided via Environment Variables will override configuration data provided via JSON files.
34
+
35
+ ## Accssing Configuration Data
36
+
37
+ There are two methods of directly accessing Configuration data directly. The first is by using first-class attributes in python, and the second is by using a `get(...)` method.
38
+
39
+ Consider the following code which demonstrates both methods, where all three of these statements are equivalent:
40
+
41
+ ```python
42
+ value = configuration.ConnectionStrings.SampleDb
43
+ value = configuration.get('ConnectionStrings:SampleDb')
44
+ value = configuration.get('ConnectionStrings__SampleDb')
45
+ ```
46
+
47
+ When accessing hierarchical data you can use a double-underscore delimiter or a colon delimiter, they are equivalent. Devs and Ops from different walks will likely prefer one form over the other, so both are supported.
48
+
49
+ ## Binding to Configuration Data
50
+
51
+ It's also possible to bind configuration data to complex types. While some of the implementation is currently naive, it should work well for the vast majority of use-cases. If you find your particular edge case does not work well please reach out to me and I will work with you to implement a sensible solution. Consider the following Python code:
52
+
53
+ ```python
54
+ json = """{
55
+ "ConnectionStrings": {
56
+ "SampleDb": "my_cxn_string"
57
+ },
58
+ "EnableSwagger": true,
59
+ "MaxBatchSize": 100
60
+ }"""
61
+
62
+ class ConnStrs:
63
+ """An ugly class name to demonstrate the class name does not matter."""
64
+ SampleDb:str = None
65
+
66
+ class AppSettings:
67
+ ConnectionStrings:ConnStrs = None
68
+ EnableSwagger:bool = None
69
+ MaxBatchSize:int = None
70
+
71
+ configuration = ConfigurationBuilder()\
72
+ .addProvider(JsonConfigurationProvider(json=json))
73
+ .build()
74
+
75
+ settings = AppSettings()
76
+ configuration.bind(settings)
77
+ ```
78
+
79
+ The resulting `settings` object will contain all of the configuration data properly typed according to type hints.
80
+
81
+ It is also possible to bind to a subset of a configuration, building upon the above, consider the following:
82
+
83
+ ```python
84
+ connectionStrings = configuration.bind(ConnectionStrings(), 'ConnectionStrings')
85
+ ```
86
+
87
+ ### Projecting Configuration Data as a Dictionary
88
+
89
+ Laslty, as a convenience item you can materialize a `Configuration` instance as a Dictionary. This is primarily included as a debugging aid, but, if you have some chunk of code that can consume a dictionary but can't consume a Python object (it happens) then you can get at a dictionary as follows:
90
+
91
+ ```python
92
+ config:Configuration = builder.build()
93
+ my_dict = config.toDictionary()
94
+ print(my_dict)
95
+ ```
96
+
97
+ ## Providers
98
+
99
+ ### Command-Line
100
+
101
+ The `CommandLineConfigurationProvider` allows configuration data to be provided via command-line interface. This provider will process variables in the following forms as key-value pairs:
102
+
103
+ ```
104
+ --ConnectionStrings__SampleDb 'my_connection_string'
105
+ ```
106
+
107
+ ```
108
+ 'ConnectionStrings__SampleDb=my_connection_string'
109
+ ```
110
+
111
+ ```
112
+ --ConnectionStrings__SampleDb=my_connection_string
113
+ ```
114
+
115
+ All three of these forms result in a configuration object with the following state (represented as JSON):
116
+
117
+ ```json
118
+ {
119
+ "ConnectionStrings" : {
120
+ "SampleDb": "my_connection_string"
121
+ }
122
+ }
123
+ ```
124
+
125
+ Take note that the leading dashes are stripped.
126
+
127
+ This double-underscore convention is a convention borrowed from other platforms/frameworks.
128
+
129
+ It's also possible to use a colon in lieu of a double_underscore, but this may have unintended side-effects depending on the shell or platform being used:
130
+
131
+ ```
132
+ 'ConnectionStrings:SampleDb=my_connection_string'
133
+ ```
134
+
135
+ This provider was implemented in a way that it does not interfere with libraries such as `argparse`, and should work as expected with a well-formed command-line interface.
136
+
137
+ ### Environment Variables
138
+
139
+ The `EnvironmentConfigurationProvider` consumes environment variables as key-value pairs. For example, consider these `bash` exports:
140
+
141
+ ```bash
142
+ export ConnectionStrings__SampleDb=my_cxn_string
143
+ export ConnectionStrings__AnotherDb=another_cxn_string
144
+ ```
145
+
146
+ The above will resule in a Configuration object with the following state (represented as JSON):
147
+
148
+ ```json
149
+ {
150
+ "ConnectionStrings": {
151
+ "SampleDb": "my_cxn_string",
152
+ "AnotherDb": "another_cxn_string"
153
+ }
154
+ }
155
+ ```
156
+
157
+ ### JSON
158
+
159
+ The `JsonConfigurationProvider` class allows you to load configuration data from a JSON file, JSON string, or JSON stream. Consider the following JSON:
160
+
161
+ ```json
162
+ {
163
+ "Logging": {
164
+ "Default": "Debug"
165
+ },
166
+ "ConnectionStrings": {
167
+ "SampleDb": "my_cxn_string"
168
+ },
169
+ }
170
+ ```
171
+
172
+ When loaded using `JsonConfigurationProvider` the resulting `Configuration` object will have attribute names and a structure which matches the JSON, consider the following Python code (where "example.json" contains the above JSON):
173
+
174
+ ```python
175
+ from appsettings2 import *
176
+ from appsettings2.providers import *
177
+
178
+ config = ConfigurationBuilder(normalize=True)\
179
+ .addProvider(JsonConfigurationProvider('example.json'))\
180
+ .build()
181
+
182
+ print(config.ConnectionStrings.SampleDb)
183
+ ```
184
+ Outputs: `my_cxn_string` (as expected.)
185
+
186
+ ### TOML
187
+
188
+ The `TomlConfigurationProvider` should be functionally identical to the JSON provider, with the obvious difference that it loads Configuration data from a toml file.
189
+
190
+ ### YAML
191
+
192
+ The `YamlConfigurationProvider` should be functionally identical to the JSON and TOML providers, with the obvious difference that it loads Configuration data from a yaml file.
193
+
194
+ ## A note on `normalize` parameters..
195
+
196
+ `ConfigurationBuilder` accepts a parameter `normalize:bool` which defaults to `False` causing `bind()` to behave in a case-sensitive fashion.
197
+
198
+ When set to `True` it causes the internal implementation of `Configuration` to normalize all configuration keys to uppercase, and consequently, `bind(...)` is no longer case-sensitive.
199
+
200
+ However, the resulting `Configuration` object attribute names also become normalized to upper case which may not be desired by some developers.
201
+
202
+ Probably most devs and devops working in enterprise environments will want to adopt `normalize=True` as a matter of practice. This will allow devops to conform around the use of upper-case key names in configuration systems, and allow devs to use whatever casing rules make the most sense for their projects (since `bind()` will still work as expected.) This does assume developers are creating concerete configuration types (you ARE doing that, correct?)
203
+
204
+ I would have preferred to make `normalize=True` by default, but, I suspect someone out there will want to use `Configuration` instances directly rather than create bind targets and this would have resulted in attribute names that were upper case. To illustrate, from one of the early examples in this doc would instead look like this:
205
+
206
+ ```python
207
+ value = configuration.CONNECTIONSTRINGS.SAMPLEDB # <-- this
208
+ value = configuration.get('ConnectionStrings:SampleDb')
209
+ value = configuration.get('ConnectionStrings__SampleDb')
210
+ ```
211
+
212
+ In the future I may further abstract this detail away so we get the best of both worlds, but for now there is the parameter to let you control the behavior.
213
+
214
+ ## Conclusion
215
+
216
+ If you made it this far, this library is probably what you were looking for. I'm certain there are other libraries which accomplish much of what I am attempting to accomplish here, but I wanted something i can directly support over the coming years. That, and the nearest equivalent to what I wanted fell out of active development nearly a decade ago and I just didn't feel comfortable adopting it for new work.
@@ -0,0 +1,26 @@
1
+ [tool.poetry]
2
+ name = "appsettings2"
3
+ version = "0.0.1"
4
+ description = "A python library that unifies configuration sources into a Configuration object that can be bound to complex types, or accessed directly for configuration data."
5
+ authors = ["Shaun Wilson <mrshaunwilson@msn.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ exclude = [
9
+ "tests"
10
+ ]
11
+ packages = [
12
+ { include = "**/*.py", from = "src", to = "appsettings2", format = ["sdist", "wheel"] }
13
+ ]
14
+
15
+ [tool.poetry.urls]
16
+ documentation = "https://github.com/wilson0x4d/appsettings2/README.md"
17
+ homepage = "https://github.com/wilson0x4d/appsettings2"
18
+ repository = "https://github.com/wilson0x4d/appsettings2.git"
19
+
20
+ [tool.poetry.dependencies]
21
+ python = "^3.11"
22
+ pyyaml = "^6.0.2"
23
+
24
+ [build-system]
25
+ requires = ["poetry-core"]
26
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,113 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from . import ConfigurationException
5
+ import json
6
+ import types
7
+ from typing import Any, TypeAlias, get_type_hints
8
+
9
+ any:TypeAlias = Any
10
+
11
+ class Configuration:
12
+ """A configuration class which creates a layer of indirection between configuration providers and configuration consumers."""
13
+
14
+ __normalize:bool
15
+
16
+ def __init__(self, *, normalize:bool = False):
17
+ """By default keys are ingest as-is, pass `True` for `normalize` to normalize kets to lower case."""
18
+ self.__normalize = normalize
19
+
20
+ def __str__(self) -> str:
21
+ d:dict = self.toDictionary()
22
+ return json.dumps(d)
23
+
24
+ def __recursiveBind(self, target:object, source:'Configuration') -> any:
25
+ targetTypeHints = get_type_hints(target)
26
+ for aname in dir(target):
27
+ if aname.startswith('_'):
28
+ continue
29
+ lval = getattr(target, aname)
30
+ if isinstance(lval, types.FunctionType) or isinstance(lval, types.MethodType):
31
+ continue
32
+ ahint = targetTypeHints.get(aname)
33
+ ahintstr = str(ahint)
34
+ rval = source.get(aname)
35
+ if isinstance(rval, Configuration):
36
+ if ahint is dict or ahintstr.startswith('dict['):
37
+ lval = rval.toDictionary()
38
+ setattr(target, aname, lval)
39
+ elif ahint is list or ahintstr.startswith('list['):
40
+ # naive behavior
41
+ setattr(target, aname, rval)
42
+ else:
43
+ if lval == None:
44
+ lval = ahint()
45
+ setattr(target, aname, lval)
46
+ self.__recursiveBind(lval, rval)
47
+ elif rval != None:
48
+ if ahint is float:
49
+ v = float(rval)
50
+ setattr(target, aname, v)
51
+ elif ahint is int:
52
+ v = int(rval)
53
+ setattr(target, aname, v)
54
+ elif ahint is str:
55
+ v = str(rval)
56
+ setattr(target, aname, v)
57
+ else:
58
+ # naive behavior
59
+ setattr(target, aname, rval)
60
+ else:
61
+ setattr(target, aname, None)
62
+ return target
63
+
64
+ def bind(self, target:object, key:str|None = None) -> any:
65
+ """Binds the configuration values into the target object.
66
+
67
+ Can optionally specify a configuration key to bind from."""
68
+ if not target:
69
+ raise ConfigurationException('Missing required argument: target')
70
+ if key == None:
71
+ return self.__recursiveBind(target, self)
72
+ else:
73
+ return self.__recursiveBind(target, self.get(key))
74
+
75
+ def get(self, key:str, default:any = None) -> any:
76
+ """Gets the configuration element associated with the specified key."""
77
+ if self.__normalize:
78
+ key = key.lower()
79
+ parts = key.replace('__', '.').replace(':', '.').split('.')
80
+ o = self
81
+ for part in parts:
82
+ if not hasattr(o, part):
83
+ return default
84
+ o = getattr(o, part)
85
+ return o
86
+
87
+ def set(self, key:str, value:any) -> None:
88
+ """Sets the configuration element for the specified key."""
89
+ if self.__normalize:
90
+ key = key.lower()
91
+ parts = key.replace('__', '.').replace(':', '.').split('.')
92
+ o = self
93
+ for i in range(len(parts) - 1):
94
+ if not hasattr(o, parts[i]):
95
+ setattr(o, parts[i], Configuration(normalize=self.__normalize))
96
+ o = getattr(o, parts[i])
97
+ if hasattr(o, parts[-1]):
98
+ delattr(o, parts[-1])
99
+ setattr(o, parts[-1], value)
100
+
101
+ def toDictionary(self) -> dict:
102
+ """Projects a dictionary from the configuration object."""
103
+ result = {}
104
+ for aname in dir(self):
105
+ if aname.startswith('_'):
106
+ continue
107
+ aval = getattr(self, aname)
108
+ if isinstance(aval, types.FunctionType) or isinstance(aval, types.MethodType):
109
+ continue
110
+ if isinstance(aval, Configuration):
111
+ aval = aval.toDictionary()
112
+ result[aname] = aval
113
+ return result
@@ -0,0 +1,32 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from . import Configuration, ConfigurationException
5
+ from .providers import ConfigurationProvider
6
+ import typing
7
+
8
+ any: typing.TypeAlias = typing.Any
9
+
10
+ class ConfigurationBuilder:
11
+ """A class which builds a Configuration object from one ore more ConfigurationProvider instances."""
12
+
13
+ __normalize:bool
14
+ __providers:list[ConfigurationProvider]
15
+
16
+ def __init__(self, *, normalize:bool = False):
17
+ self.__normalize = normalize
18
+ self.__providers = []
19
+
20
+ def addProvider(self, provider:ConfigurationProvider) -> 'ConfigurationBuilder':
21
+ """Adds the specified provider to this builder instance."""
22
+ if not provider:
23
+ raise ConfigurationException('Missing required argument: provider')
24
+ self.__providers.append(provider)
25
+ return self
26
+
27
+ def build(self) -> Configuration:
28
+ """Builds a Configuration object from all providers which have been added to this builder."""
29
+ configuration = Configuration(normalize=self.__normalize)
30
+ for provider in self.__providers:
31
+ provider.populateConfiguration(configuration)
32
+ return configuration
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ class ConfigurationException(Exception):
5
+ """An exception raised by `appsettings2` library."""
6
+ def __init__(self, reason:str):
7
+ super().__init__(reason)
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .providers import *
5
+ from .Configuration import Configuration
6
+ from .ConfigurationBuilder import ConfigurationBuilder
7
+ from .ConfigurationException import ConfigurationException
8
+
9
+ __version__ = '0.0.1'
@@ -0,0 +1,39 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from . import ConfigurationProvider
5
+ from .. import Configuration
6
+ import sys
7
+
8
+ class CommandLineConfigurationProvider(ConfigurationProvider):
9
+ """Processes the command-line and provides args as Configuration data."""
10
+
11
+ __argv:list[str]
12
+
13
+ def __init__(self, argv:list[str] = None):
14
+ self.__argv = argv if argv != None else sys.argv
15
+
16
+ def populateConfiguration(self, configuration:Configuration):
17
+ if self.__argv == None:
18
+ return
19
+ pending_key = None
20
+ for arg in self.__argv:
21
+ safe_arg = arg.lstrip('-')
22
+ eqidx = safe_arg.find('=')
23
+ if pending_key:
24
+ if (not arg.startswith('-')) and (eqidx < 1):
25
+ k = pending_key
26
+ v = arg.lstrip('=')
27
+ configuration.set(k, v)
28
+ continue
29
+ else:
30
+ configuration.set(pending_key, True)
31
+ pending_key = None
32
+ if eqidx > 0:
33
+ k = safe_arg[0:eqidx]
34
+ v = safe_arg[eqidx+1:len(safe_arg)]
35
+ configuration.set(k, v)
36
+ elif arg.startswith('--'):
37
+ pending_key = safe_arg
38
+ if pending_key != None:
39
+ configuration.set(pending_key, True)
@@ -0,0 +1,12 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .. import Configuration
5
+ from abc import ABC as abstract, abstractmethod
6
+
7
+ class ConfigurationProvider(abstract):
8
+
9
+ @abstractmethod
10
+ def populateConfiguration(self, configuration:Configuration) -> None:
11
+ """The ConfigurationProvider will populate the provided Configuration instance."""
12
+ pass
@@ -0,0 +1,13 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from . import ConfigurationProvider
5
+ from .. import Configuration
6
+ import os
7
+
8
+ class EnvironmentConfigurationProvider(ConfigurationProvider):
9
+ """Processes environment variables and provides them as Configuration data."""
10
+
11
+ def populateConfiguration(self, configuration:Configuration):
12
+ for k in os.environ:
13
+ configuration.set(k, os.environ[k])
@@ -0,0 +1,47 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from . import ConfigurationProvider
5
+ from .. import Configuration, ConfigurationException
6
+ import json as _json
7
+ import os
8
+ from typing import Any, TypeAlias
9
+
10
+ FileDescriptor: TypeAlias = int
11
+ any: TypeAlias = Any
12
+
13
+ class JsonConfigurationProvider(ConfigurationProvider):
14
+ """Processes a json string, json file, or json stream and provides the content as Configuration data."""
15
+
16
+ __obj:dict
17
+
18
+ def __init__(self, filepath:str = None, *, json:str = None, fd:FileDescriptor = None, required:bool = True):
19
+ if filepath:
20
+ if os.path.isfile(filepath):
21
+ with open(filepath, 'rt') as file:
22
+ json = file.read()
23
+ elif required:
24
+ raise ConfigurationException(f'Missing required file: {filepath}')
25
+ elif fd:
26
+ with open(fd, 'rt') as file:
27
+ json = file.read()
28
+ if json:
29
+ self.__obj = _json.loads(json)
30
+ else:
31
+ self.__obj = None
32
+
33
+ def __populateRecursive(self, configuration:Configuration, prefix:str, o:dict):
34
+ for kvp in o.items():
35
+ if isinstance(kvp[1], dict):
36
+ self.__populateRecursive(configuration, f'{prefix}__{kvp[0]}', kvp[1])
37
+ else:
38
+ configuration.set(f'{prefix}__{kvp[0]}', kvp[1])
39
+
40
+ def populateConfiguration(self, configuration:Configuration):
41
+ if self.__obj == None:
42
+ return
43
+ for kvp in self.__obj.items():
44
+ if isinstance(kvp[1], dict):
45
+ self.__populateRecursive(configuration, kvp[0], kvp[1])
46
+ else:
47
+ configuration.set(f'{kvp[0]}', kvp[1])
@@ -0,0 +1,47 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from . import ConfigurationProvider
5
+ from .. import Configuration, ConfigurationException
6
+ import os
7
+ import tomllib as _toml
8
+ from typing import Any, TypeAlias
9
+
10
+ FileDescriptor: TypeAlias = int
11
+ any: TypeAlias = Any
12
+
13
+ class TomlConfigurationProvider(ConfigurationProvider):
14
+ """Processes a toml string, toml file, or toml stream and provides the content as Configuration data."""
15
+
16
+ __obj:dict
17
+
18
+ def __init__(self, filepath:str = None, *, toml:str = None, fd:FileDescriptor = None, required:bool = True):
19
+ if filepath:
20
+ if os.path.isfile(filepath):
21
+ with open(filepath, 'rt') as file:
22
+ toml = file.read()
23
+ elif required:
24
+ raise ConfigurationException(f'Missing required file: {filepath}')
25
+ elif fd:
26
+ with open(fd, 'rt') as file:
27
+ toml = file.read()
28
+ if toml:
29
+ self.__obj = _toml.loads(toml)
30
+ else:
31
+ self.__obj = None
32
+
33
+ def __populateRecursive(self, configuration:Configuration, prefix:str, o:dict):
34
+ for kvp in o.items():
35
+ if isinstance(kvp[1], dict):
36
+ self.__populateRecursive(configuration, f'{prefix}__{kvp[0]}', kvp[1])
37
+ else:
38
+ configuration.set(f'{prefix}__{kvp[0]}', kvp[1])
39
+
40
+ def populateConfiguration(self, configuration:Configuration):
41
+ if self.__obj == None:
42
+ return
43
+ for kvp in self.__obj.items():
44
+ if isinstance(kvp[1], dict):
45
+ self.__populateRecursive(configuration, kvp[0], kvp[1])
46
+ else:
47
+ configuration.set(f'{kvp[0]}', kvp[1])
@@ -0,0 +1,50 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from . import ConfigurationProvider
5
+ from .. import Configuration, ConfigurationException
6
+ from io import StringIO
7
+ import os
8
+ from typing import Any, TypeAlias
9
+ import yaml as _yaml
10
+
11
+ FileDescriptor: TypeAlias = int
12
+ any: TypeAlias = Any
13
+
14
+ class YamlConfigurationProvider(ConfigurationProvider):
15
+ """Processes a yaml string, yaml file, or yaml stream and provides the content as Configuration data."""
16
+
17
+ __obj:dict
18
+
19
+ def __init__(self, filepath:str = None, *, yaml:str = None, fd:FileDescriptor = None, required:bool = True):
20
+ if filepath:
21
+ if os.path.isfile(filepath):
22
+ with open(filepath, 'rt') as file:
23
+ self.__obj = _yaml.safe_load(file)
24
+ elif required:
25
+ raise ConfigurationException(f'Missing required file: {filepath}')
26
+ elif fd:
27
+ with open(fd, 'rt') as file:
28
+ self.__obj = _yaml.safe_load(file)
29
+ elif yaml:
30
+ stream = StringIO(yaml)
31
+ self.__obj = _yaml.safe_load(stream)
32
+ stream.close()
33
+ else:
34
+ self.__obj = None
35
+
36
+ def __populateRecursive(self, configuration:Configuration, prefix:str, o:dict):
37
+ for kvp in o.items():
38
+ if isinstance(kvp[1], dict):
39
+ self.__populateRecursive(configuration, f'{prefix}__{kvp[0]}', kvp[1])
40
+ else:
41
+ configuration.set(f'{prefix}__{kvp[0]}', kvp[1])
42
+
43
+ def populateConfiguration(self, configuration:Configuration):
44
+ if self.__obj == None:
45
+ return
46
+ for kvp in self.__obj.items():
47
+ if isinstance(kvp[1], dict):
48
+ self.__populateRecursive(configuration, kvp[0], kvp[1])
49
+ else:
50
+ configuration.set(f'{kvp[0]}', kvp[1])
@@ -0,0 +1,9 @@
1
+ # SPDX-FileCopyrightText: © 2024 Shaun Wilson
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ from .ConfigurationProvider import ConfigurationProvider
5
+ from .CommandLineConfigurationProvider import CommandLineConfigurationProvider
6
+ from .EnvironmentConfigurationProvider import EnvironmentConfigurationProvider
7
+ from .JsonConfigurationProvider import JsonConfigurationProvider
8
+ from .TomlConfigurationProvider import TomlConfigurationProvider
9
+ from .YamlConfigurationProvider import YamlConfigurationProvider