appsettings2 0.1.4__tar.gz → 0.3.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.

Potentially problematic release.


This version of appsettings2 might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: appsettings2
3
- Version: 0.1.4
3
+ Version: 0.3.0
4
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
5
  License: MIT
6
6
  Author: Shaun Wilson
@@ -199,7 +199,7 @@ When loaded using `JsonConfigurationProvider` the resulting `Configuration` obje
199
199
  from appsettings2 import *
200
200
  from appsettings2.providers import *
201
201
 
202
- config = ConfigurationBuilder(normalize=True)\
202
+ config = ConfigurationBuilder()\
203
203
  .addProvider(JsonConfigurationProvider('example.json'))\
204
204
  .build()
205
205
 
@@ -215,25 +215,88 @@ The `TomlConfigurationProvider` should be functionally identical to the JSON pro
215
215
 
216
216
  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.
217
217
 
218
- ## A note on `normalize` parameters..
218
+ ## Special Attribute Name Handling
219
219
 
220
- `ConfigurationBuilder` accepts a parameter `normalize:bool` which defaults to `False` causing `bind()` to behave in a case-sensitive fashion.
220
+ ### Upper-Case Attribute Names
221
221
 
222
- 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.
222
+ `ConfigurationBuilder` accepts a parameter `normalize:bool` which defaults to `False` causing `Configuration` attributes to preserve the casing of keys from the input configuration.
223
223
 
224
- However, the resulting `Configuration` object attribute names also become normalized to upper case which may not be desired by some developers.
224
+ If you pass `normalize=True`, all `Configuration` instances created by that builder will normalize `Configuration` attributes to upper-case.
225
225
 
226
- 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?)
226
+ In both cases the `Configuration` object allows access to data in a case-insensitive fashion. Normalization _ONLY_ affects the resulting object attributes.
227
227
 
228
- 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, one of the early examples in this doc would instead look like this:
228
+ To illustrate, consider the following snippet:
229
229
 
230
230
  ```python
231
- value = configuration.CONNECTIONSTRINGS.SAMPLEDB # <-- this
232
- value = configuration.get('ConnectionStrings:SampleDb')
233
- value = configuration.get('ConnectionStrings__SampleDb')
231
+ configuration = Configuration(normalize=True)
232
+ configuration.set('ConnectionStrings__SampleDb', 'blah')
233
+ # normalized attributes looks like this:
234
+ value = configuration.CONNECTIONSTRINGS.SAMPLEDB
235
+ # but you can still access the data case-insensitive:
236
+ value = configuration.get('connectionstrings__SAMPLEDB')
237
+ value = configuration['cOnNeCtioNsTriNGs']['sampledb']
238
+ # however, by default (where normalize=False):
239
+ configuration = Configuration()
240
+ configuration.set('ConnectionStrings__SampleDb', 'blah')
241
+ # attributes look like this:
242
+ value = configuration.ConnectionStrings.SampleDb
243
+ # and as before, you have case-insensitive access:
244
+ value = configuration.get('connectionstrings__SAMPLEDB')
245
+ value = configuration['cOnNeCtioNsTriNGs']['sampledb']
246
+ ```
247
+
248
+ Normalization has no effect on `bind(...)`, which operates in a case-insensitive fashion internally.
249
+
250
+ ### Lexer-friendly Attribute Names
251
+
252
+ `ConfigurationBuilder` accepts a parameter `scrubkeys:bool` which defaults to `False` causing `Configuration` attributes to preserve the keys from the input configuration even if they would be inaccessible from Python code.
253
+
254
+ If you pass `scrubkeys=True`, all `Configuration` instances created by that builder will generate lexically accessible `Configuration` attributes.
255
+
256
+ This is not enabled by default because it introduces an edge case where configuration keys may collide, albeit very unlikely, this is explained below.
257
+
258
+ Some configuration sources might produce attribute names which are not accessible from Python code. Consider the following JSON snippet:
259
+
260
+ ```json
261
+ {
262
+ "query-tab" : {
263
+ "fragment#left": {
264
+ "input": true
265
+ }
266
+ }
267
+ }
268
+ ```
269
+
270
+ This configuration will load, and you can still access the data using keyed methods such as `get(...)` and `set(...)`, but the resulting `Configuration` object will have attributes that cannot be accessed from Python code:
271
+
272
+ ```python
273
+ if True == configuration.query-tab.fragment#left.input:
274
+ pass
275
+ ```
276
+
277
+ To accomodate configurations such as these and make them accessible via `Configuration` attributes you may pass `scrubkeys=True`. This will cause any lexically invalid characters to be transformed into an underscore `_` character.
278
+
279
+ Although attribute names are transformed, keys are not. Therefore, given the above JSON snippet the following are equivalent:
280
+
281
+ ```python
282
+ # access via object attributes
283
+ configuration.query_tab.fragement_left.input
284
+ # access using indexers
285
+ configuration['query-tab']['fragment#left']
286
+ # access using other 'key' methods
287
+ configuration.get('query-tab').has_key('fragment#left')
288
+ ```
289
+
290
+ Lastly, this _does_ make it possible for configuration values to collide, for example, these two keys will collide:
291
+
292
+ ```json
293
+ {
294
+ "key#1": true,
295
+ "key-1": false
296
+ }
234
297
  ```
235
298
 
236
- In the future I may 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.
299
+ Attempting to access these values will result in a value of `False` being returned, and setting the value of one key will affect the value of the other, this is because internally there will be only one storage slot/attribute that is shared between them. Although an unlikely scenario, it is for this reason the feature is opt-in only.
237
300
 
238
301
  ## Conclusion
239
302
 
@@ -178,7 +178,7 @@ When loaded using `JsonConfigurationProvider` the resulting `Configuration` obje
178
178
  from appsettings2 import *
179
179
  from appsettings2.providers import *
180
180
 
181
- config = ConfigurationBuilder(normalize=True)\
181
+ config = ConfigurationBuilder()\
182
182
  .addProvider(JsonConfigurationProvider('example.json'))\
183
183
  .build()
184
184
 
@@ -194,25 +194,88 @@ The `TomlConfigurationProvider` should be functionally identical to the JSON pro
194
194
 
195
195
  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.
196
196
 
197
- ## A note on `normalize` parameters..
197
+ ## Special Attribute Name Handling
198
198
 
199
- `ConfigurationBuilder` accepts a parameter `normalize:bool` which defaults to `False` causing `bind()` to behave in a case-sensitive fashion.
199
+ ### Upper-Case Attribute Names
200
200
 
201
- 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.
201
+ `ConfigurationBuilder` accepts a parameter `normalize:bool` which defaults to `False` causing `Configuration` attributes to preserve the casing of keys from the input configuration.
202
202
 
203
- However, the resulting `Configuration` object attribute names also become normalized to upper case which may not be desired by some developers.
203
+ If you pass `normalize=True`, all `Configuration` instances created by that builder will normalize `Configuration` attributes to upper-case.
204
204
 
205
- 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?)
205
+ In both cases the `Configuration` object allows access to data in a case-insensitive fashion. Normalization _ONLY_ affects the resulting object attributes.
206
206
 
207
- 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, one of the early examples in this doc would instead look like this:
207
+ To illustrate, consider the following snippet:
208
208
 
209
209
  ```python
210
- value = configuration.CONNECTIONSTRINGS.SAMPLEDB # <-- this
211
- value = configuration.get('ConnectionStrings:SampleDb')
212
- value = configuration.get('ConnectionStrings__SampleDb')
210
+ configuration = Configuration(normalize=True)
211
+ configuration.set('ConnectionStrings__SampleDb', 'blah')
212
+ # normalized attributes looks like this:
213
+ value = configuration.CONNECTIONSTRINGS.SAMPLEDB
214
+ # but you can still access the data case-insensitive:
215
+ value = configuration.get('connectionstrings__SAMPLEDB')
216
+ value = configuration['cOnNeCtioNsTriNGs']['sampledb']
217
+ # however, by default (where normalize=False):
218
+ configuration = Configuration()
219
+ configuration.set('ConnectionStrings__SampleDb', 'blah')
220
+ # attributes look like this:
221
+ value = configuration.ConnectionStrings.SampleDb
222
+ # and as before, you have case-insensitive access:
223
+ value = configuration.get('connectionstrings__SAMPLEDB')
224
+ value = configuration['cOnNeCtioNsTriNGs']['sampledb']
225
+ ```
226
+
227
+ Normalization has no effect on `bind(...)`, which operates in a case-insensitive fashion internally.
228
+
229
+ ### Lexer-friendly Attribute Names
230
+
231
+ `ConfigurationBuilder` accepts a parameter `scrubkeys:bool` which defaults to `False` causing `Configuration` attributes to preserve the keys from the input configuration even if they would be inaccessible from Python code.
232
+
233
+ If you pass `scrubkeys=True`, all `Configuration` instances created by that builder will generate lexically accessible `Configuration` attributes.
234
+
235
+ This is not enabled by default because it introduces an edge case where configuration keys may collide, albeit very unlikely, this is explained below.
236
+
237
+ Some configuration sources might produce attribute names which are not accessible from Python code. Consider the following JSON snippet:
238
+
239
+ ```json
240
+ {
241
+ "query-tab" : {
242
+ "fragment#left": {
243
+ "input": true
244
+ }
245
+ }
246
+ }
247
+ ```
248
+
249
+ This configuration will load, and you can still access the data using keyed methods such as `get(...)` and `set(...)`, but the resulting `Configuration` object will have attributes that cannot be accessed from Python code:
250
+
251
+ ```python
252
+ if True == configuration.query-tab.fragment#left.input:
253
+ pass
254
+ ```
255
+
256
+ To accomodate configurations such as these and make them accessible via `Configuration` attributes you may pass `scrubkeys=True`. This will cause any lexically invalid characters to be transformed into an underscore `_` character.
257
+
258
+ Although attribute names are transformed, keys are not. Therefore, given the above JSON snippet the following are equivalent:
259
+
260
+ ```python
261
+ # access via object attributes
262
+ configuration.query_tab.fragement_left.input
263
+ # access using indexers
264
+ configuration['query-tab']['fragment#left']
265
+ # access using other 'key' methods
266
+ configuration.get('query-tab').has_key('fragment#left')
267
+ ```
268
+
269
+ Lastly, this _does_ make it possible for configuration values to collide, for example, these two keys will collide:
270
+
271
+ ```json
272
+ {
273
+ "key#1": true,
274
+ "key-1": false
275
+ }
213
276
  ```
214
277
 
215
- In the future I may 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.
278
+ Attempting to access these values will result in a value of `False` being returned, and setting the value of one key will affect the value of the other, this is because internally there will be only one storage slot/attribute that is shared between them. Although an unlikely scenario, it is for this reason the feature is opt-in only.
216
279
 
217
280
  ## Conclusion
218
281
 
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "appsettings2"
3
- version = "0.1.4"
3
+ version = "0.3.0"
4
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
5
  authors = ["Shaun Wilson <mrshaunwilson@msn.com>"]
6
6
  license = "MIT"
@@ -3,44 +3,52 @@
3
3
 
4
4
  from . import ConfigurationException
5
5
  import json
6
+ import re
6
7
  import types
7
8
  from typing import Any, TypeAlias, get_type_hints
9
+ import unicodedata
8
10
 
9
11
  any:TypeAlias = Any
10
12
 
11
13
  class Configuration:
12
14
  """A configuration class which creates a layer of indirection between configuration providers and configuration consumers."""
13
15
 
16
+ __key_scrub_re:re.Pattern
17
+ __keys:dict[str, str]
14
18
  __normalize:bool
15
- __len:int = 0
16
19
 
17
- def __init__(self, *, normalize:bool = False):
20
+ def __init__(self, *, normalize:bool = False, scrubkeys:bool = False):
18
21
  """By default keys are ingest as-is, pass `True` for `normalize` to normalize kets to lower case."""
22
+ self.__keys = {}
19
23
  self.__normalize = normalize
24
+ self.__key_scrub_re = None if not scrubkeys else re.compile(r'[^A-Za-z0-9_]', re.IGNORECASE | re.UNICODE)
20
25
 
21
- def __str__(self) -> str:
22
- d:dict = self.toDictionary()
23
- return json.dumps(d)
26
+ def __delitem__(self, key:str) -> None:
27
+ key = key.upper()
28
+ k = self.__keys.get(key)
29
+ if k != None:
30
+ delattr(self, k)
31
+ self.__keys.pop(key)
24
32
 
25
33
  def __getitem__(self, key:str) -> any:
26
- if not hasattr(self, key):
27
- raise KeyError()
28
- else:
29
- return self.get(key)
34
+ parts = key.replace('__', '.').replace(':', '.').split('.')
35
+ o = self
36
+ for part in parts:
37
+ if o == self:
38
+ k = self.__keys.get(part.upper())
39
+ if k == None:
40
+ raise KeyError()
41
+ else:
42
+ o = getattr(o, self.__scrub_key(k))
43
+ else:
44
+ o = o[part]
45
+ return o
30
46
 
31
47
  def __iter__(self):
32
- return iter(self.keys())
48
+ return iter(self.__keys.values())
33
49
 
34
50
  def __len__(self) -> int:
35
- return self.__len
36
-
37
- def __setitem__(self, key:str, value:any) -> None:
38
- self.set(key, value)
39
-
40
- def __delitem__(self, key:str) -> None:
41
- if hasattr(self, key):
42
- delattr(self, key)
43
- self.__len -= 1
51
+ return len(self.__keys)
44
52
 
45
53
  def __recursiveBind(self, target:object, source:'Configuration') -> any:
46
54
  targetTypeHints = get_type_hints(target)
@@ -58,8 +66,7 @@ class Configuration:
58
66
  lval = rval.toDictionary()
59
67
  setattr(target, aname, lval)
60
68
  elif ahint is list or ahintstr.startswith('list['):
61
- # naive behavior
62
- setattr(target, aname, rval)
69
+ raise ConfigurationException(f'lval ({ahint!r}) is incompatible with rval (Configuration)')
63
70
  else:
64
71
  if lval == None:
65
72
  lval = ahint()
@@ -82,6 +89,31 @@ class Configuration:
82
89
  setattr(target, aname, None)
83
90
  return target
84
91
 
92
+ def __scrub_key(self, key:str) -> str:
93
+ """Scrubs a key for use as an attribute/identifier according to the Python lexer/standard."""
94
+ key = key\
95
+ .replace('__', '.')\
96
+ .replace(':', '.')
97
+ return key if None == self.__key_scrub_re else \
98
+ self.__key_scrub_re.sub(
99
+ self.__scrub_uc,
100
+ unicodedata.normalize(
101
+ 'NFKC',
102
+ key))
103
+
104
+ def __scrub_uc(self, m:re.Match) -> str:
105
+ match unicodedata.category(m[0]):
106
+ case 'Lu' | 'Ll' | 'Lt' | 'Lm' | 'Lo' | 'Nl' | 'Mn' | 'Mc' | 'Nd' | 'Pc' :
107
+ return m[0]
108
+ case _:
109
+ return '_'
110
+
111
+ def __setitem__(self, key:str, value:any) -> None:
112
+ self.set(key, value)
113
+
114
+ def __str__(self) -> str:
115
+ return json.dumps(self.toDictionary())
116
+
85
117
  def bind(self, target:object, key:str|None = None) -> any:
86
118
  """Binds the configuration values into the target object.
87
119
 
@@ -94,46 +126,37 @@ class Configuration:
94
126
  return self.__recursiveBind(target, self.get(key))
95
127
 
96
128
  def clear(self) -> None:
97
- for k in self.keys():
98
- delattr(self, k)
99
- self.__len -= 1
129
+ while len(self.__keys) > 0:
130
+ t = self.__keys.popitem()
131
+ delattr(self, t[1])
100
132
 
101
133
  def get(self, key:str, default:any = None) -> any:
102
134
  """Gets the configuration element associated with the specified key."""
103
- if self.__normalize:
104
- key = key.lower()
105
135
  parts = key.replace('__', '.').replace(':', '.').split('.')
106
136
  o = self
107
137
  for part in parts:
108
- if not hasattr(o, part):
109
- return default
110
- o = getattr(o, part)
138
+ if o == self:
139
+ k = self.__keys.get(part.upper())
140
+ if k == None:
141
+ return default
142
+ else:
143
+ o = getattr(o, self.__scrub_key(k))
144
+ else:
145
+ o = o.get(part, default)
111
146
  return o
112
147
 
113
148
  def has_key(self, key:str) -> bool:
114
- return hasattr(self, key)
149
+ return self.__keys.get(key.upper()) != None
115
150
 
116
151
  def items(self) -> list[tuple[str,any]]:
117
152
  it = []
118
- for k in dir(self):
119
- if k.startswith('_'):
120
- continue
121
- v = getattr(self, k)
122
- if isinstance(v, types.FunctionType) or isinstance(v, types.MethodType):
123
- continue
153
+ for k in self.keys():
154
+ v = self.get(k)
124
155
  it.append((k, v))
125
156
  return it
126
157
 
127
158
  def keys(self) -> list[str]:
128
- keys = []
129
- for k in dir(self):
130
- if k.startswith('_'):
131
- continue
132
- v = getattr(self, k)
133
- if isinstance(v, types.FunctionType) or isinstance(v, types.MethodType):
134
- continue
135
- keys.append(k)
136
- return keys
159
+ return self.__keys.values()
137
160
 
138
161
  def pop(self, key:str) -> any:
139
162
  value = self[key]
@@ -143,46 +166,51 @@ class Configuration:
143
166
  def set(self, key:str, value:any) -> None:
144
167
  """Sets the configuration element for the specified key."""
145
168
  if self.__normalize:
146
- key = key.lower()
169
+ key = key.upper()
147
170
  parts = key.replace('__', '.').replace(':', '.').split('.')
148
171
  o = self
149
172
  for i in range(len(parts) - 1):
150
- if not hasattr(o, parts[i]):
151
- c = Configuration(normalize=self.__normalize)
152
- o.set(parts[i], c)
153
- o = c
173
+ if o == self:
174
+ k = self.__keys.get(parts[i].upper())
175
+ if k == None:
176
+ c = Configuration(normalize=self.__normalize, scrubkeys=(None != self.__key_scrub_re))
177
+ setattr(o, self.__scrub_key(parts[i]), c)
178
+ self.__keys[parts[i].upper()] = parts[i]
179
+ o = c
180
+ else:
181
+ o = getattr(self, self.__scrub_key(k))
154
182
  else:
155
- o = getattr(o, parts[i])
183
+ if not o.has_key(parts[i]):
184
+ c = Configuration(normalize=self.__normalize, scrubkeys=(None != self.__key_scrub_re))
185
+ o.set(parts[i], c)
186
+ o = c
187
+ else:
188
+ o = o.get(parts[i])
189
+ key = parts[-1]
156
190
  if o == self:
157
- if hasattr(o, parts[-1]):
158
- delattr(o, parts[-1])
191
+ k = self.__keys.get(key.upper())
192
+ if k != None:
193
+ setattr(self, self.__scrub_key(k), value)
159
194
  else:
160
- self.__len += 1
161
- setattr(self, parts[-1], value)
195
+ self.__keys[key.upper()] = key
196
+ setattr(self, self.__scrub_key(key), value)
162
197
  else:
163
- o.set(parts[-1], value)
198
+ o.set(key, value)
164
199
 
165
200
  def toDictionary(self) -> dict:
166
201
  """Projects a dictionary from the configuration object."""
167
202
  result = {}
168
- for aname in dir(self):
169
- if aname.startswith('_'):
170
- continue
171
- aval = getattr(self, aname)
172
- if isinstance(aval, types.FunctionType) or isinstance(aval, types.MethodType):
173
- continue
174
- if isinstance(aval, Configuration):
175
- aval = aval.toDictionary()
176
- result[aname] = aval
203
+ for k in self.__keys.values():
204
+ v = getattr(self, self.__scrub_key(k))
205
+ if isinstance(v, Configuration):
206
+ result[k] = v.toDictionary()
207
+ else:
208
+ result[k] = v
177
209
  return result
178
210
 
179
211
  def values(self) -> list[any]:
180
212
  values = []
181
- for k in dir(self):
182
- if k.startswith('_'):
183
- continue
184
- v = getattr(self, k)
185
- if isinstance(v, types.FunctionType) or isinstance(v, types.MethodType):
186
- continue
213
+ for k in self.__keys.values():
214
+ v = getattr(self, self.__scrub_key(k))
187
215
  values.append(v)
188
216
  return values
@@ -13,8 +13,9 @@ class ConfigurationBuilder:
13
13
  __normalize:bool
14
14
  __providers:list[ConfigurationProvider]
15
15
 
16
- def __init__(self, *, normalize:bool = False):
16
+ def __init__(self, *, normalize:bool = False, scrubkeys:bool = False):
17
17
  self.__normalize = normalize
18
+ self.__scrubkeys = scrubkeys
18
19
  self.__providers = []
19
20
 
20
21
  def addProvider(self, provider:ConfigurationProvider) -> 'ConfigurationBuilder':
@@ -26,7 +27,7 @@ class ConfigurationBuilder:
26
27
 
27
28
  def build(self) -> Configuration:
28
29
  """Builds a Configuration object from all providers which have been added to this builder."""
29
- configuration = Configuration(normalize=self.__normalize)
30
+ configuration = Configuration(normalize=self.__normalize, scrubkeys=self.__scrubkeys)
30
31
  for provider in self.__providers:
31
32
  provider.populateConfiguration(configuration)
32
33
  return configuration
@@ -6,4 +6,4 @@ from .Configuration import Configuration
6
6
  from .ConfigurationBuilder import ConfigurationBuilder
7
7
  from .ConfigurationException import ConfigurationException
8
8
 
9
- __version__ = '0.1.4'
9
+ __version__ = '0.3.0'
File without changes