appsettings2 2.0.14__tar.gz → 2.1.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.
- {appsettings2-2.0.14/src/appsettings2.egg-info → appsettings2-2.1.0}/PKG-INFO +3 -3
- {appsettings2-2.0.14 → appsettings2-2.1.0}/pyproject.toml +5 -5
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/Configuration.py +160 -37
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/ConfigurationBuilder.py +48 -4
- appsettings2-2.1.0/src/appsettings2/ConfigurationWatcher.py +375 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/__init__.py +7 -3
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/helpers.py +4 -2
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/ConfigurationProvider.py +2 -1
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/JsonConfigurationProvider.py +12 -4
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/TomlConfigurationProvider.py +13 -5
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/YamlConfigurationProvider.py +14 -6
- {appsettings2-2.0.14 → appsettings2-2.1.0/src/appsettings2.egg-info}/PKG-INFO +3 -3
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2.egg-info/SOURCES.txt +1 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2.egg-info/requires.txt +2 -2
- {appsettings2-2.0.14 → appsettings2-2.1.0}/LICENSE +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/README.md +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/setup.cfg +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/ConfigurationException.py +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/SKILL.md +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/CommandLineConfigurationProvider.py +1 -1
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/EnvironmentConfigurationProvider.py +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/__init__.py +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/py.typed +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2.egg-info/dependency_links.txt +0 -0
- {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: appsettings2
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Unifies configuration sources into a Configuration object that can be bound to complex types, or accessed directly for configuration data.
|
|
5
5
|
Author-email: Shaun Wilson <mrshaunwilson@msn.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -14,14 +14,14 @@ Classifier: Programming Language :: Python
|
|
|
14
14
|
Requires-Python: >=3.11
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
-
Requires-Dist: pyyaml>=6.0.
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
18
18
|
Provides-Extra: dev
|
|
19
19
|
Requires-Dist: coverage; extra == "dev"
|
|
20
20
|
Requires-Dist: flake8; extra == "dev"
|
|
21
21
|
Requires-Dist: flake8-pyproject; extra == "dev"
|
|
22
22
|
Requires-Dist: mypy; extra == "dev"
|
|
23
23
|
Requires-Dist: pep8-naming; extra == "dev"
|
|
24
|
-
Requires-Dist: punit>=1.
|
|
24
|
+
Requires-Dist: punit>=1.12.5; extra == "dev"
|
|
25
25
|
Requires-Dist: twine; extra == "dev"
|
|
26
26
|
Requires-Dist: types-PyYAML; extra == "dev"
|
|
27
27
|
Dynamic: license-file
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "appsettings2"
|
|
3
|
-
version = "2.0
|
|
3
|
+
version = "2.1.0"
|
|
4
4
|
description = "Unifies configuration sources into a Configuration object that can be bound to complex types, or accessed directly for configuration data."
|
|
5
5
|
keywords = ["appsettings", "configuration"]
|
|
6
6
|
authors = [
|
|
@@ -15,7 +15,7 @@ classifiers = [
|
|
|
15
15
|
]
|
|
16
16
|
requires-python = ">=3.11"
|
|
17
17
|
dependencies = [
|
|
18
|
-
"pyyaml>=6.0.
|
|
18
|
+
"pyyaml>=6.0.3"
|
|
19
19
|
]
|
|
20
20
|
|
|
21
21
|
[project.optional-dependencies]
|
|
@@ -25,7 +25,7 @@ dev = [
|
|
|
25
25
|
"flake8-pyproject",
|
|
26
26
|
"mypy",
|
|
27
27
|
"pep8-naming",
|
|
28
|
-
"punit (>=1.
|
|
28
|
+
"punit (>=1.12.5)",
|
|
29
29
|
"twine",
|
|
30
30
|
"types-PyYAML"
|
|
31
31
|
]
|
|
@@ -53,6 +53,6 @@ exclude = [
|
|
|
53
53
|
[tool.flake8]
|
|
54
54
|
max-line-length = 120
|
|
55
55
|
ignore = ["D100","E501"]
|
|
56
|
-
per-file-
|
|
57
|
-
"tests/*.py:D012",
|
|
56
|
+
per-file-ignores = [
|
|
57
|
+
"tests/*.py:D012,E306",
|
|
58
58
|
]
|
|
@@ -7,26 +7,42 @@ import json
|
|
|
7
7
|
import logging
|
|
8
8
|
import re
|
|
9
9
|
import types
|
|
10
|
-
from types import NoneType
|
|
11
|
-
from typing import Any, Iterator, Union, TYPE_CHECKING, get_args, get_origin, get_type_hints
|
|
12
10
|
import unicodedata
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from types import NoneType
|
|
13
|
+
from typing import Any, Callable, Iterator, Union, TYPE_CHECKING, get_args, get_origin, get_type_hints
|
|
13
14
|
|
|
14
15
|
from .ConfigurationException import ConfigurationException
|
|
15
16
|
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .ConfigurationWatcher import ConfigurationWatcher
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ChangeType(str, Enum):
|
|
22
|
+
CHANGING = 'changing'
|
|
23
|
+
CHANGED = 'changed'
|
|
24
|
+
CLOSED = 'closed'
|
|
25
|
+
|
|
16
26
|
|
|
17
27
|
class Configuration:
|
|
18
28
|
"""The :py:class:`~appsettings2.Configuration` class is how applications access configuration data populated by :py:class:`~appsettings2.providers.ConfigurationProvider` objects. It exposes configuration data through dynamic object attributes as well as a dictionary-like interface."""
|
|
19
29
|
|
|
30
|
+
__handlers: list[Callable[[Configuration, ChangeType, str | None], None]]
|
|
20
31
|
__key_scrub_re: re.Pattern[str] | None
|
|
21
32
|
__keys: dict[str, str]
|
|
22
33
|
__normalize: bool
|
|
34
|
+
__watcher: ConfigurationWatcher | None
|
|
23
35
|
|
|
24
|
-
|
|
36
|
+
disable_events: bool
|
|
37
|
+
|
|
38
|
+
def __init__(self, normalize: bool = False, scrubkeys: bool = False, disable_events: bool | None = None, watcher: ConfigurationWatcher | None = None) -> None:
|
|
25
39
|
"""
|
|
26
40
|
Initialize *Configuration* instance.
|
|
27
41
|
|
|
28
42
|
:param normalize: Option indicating whether or not attribute names should be normalized to upper-case on the resulting :py:class:`~appsettings2.Configuration` object, defaults to False.
|
|
29
43
|
:param scrubkeys: Option indicating whether or not attribute names should be scrubbed to be compatible with the Python lexer, defaults to False.
|
|
44
|
+
:param disable_events: Option indicating whether change events should be emitted, defaults to False.
|
|
45
|
+
:param watcher: Optional :py:class:`~appsettings2.ConfigurationWatcher` to associate with this configuration.
|
|
30
46
|
"""
|
|
31
47
|
self.__keys = {}
|
|
32
48
|
self.__logger = logging.getLogger('appsettings2')
|
|
@@ -36,6 +52,11 @@ class Configuration:
|
|
|
36
52
|
if not scrubkeys
|
|
37
53
|
else re.compile(r'[^A-Za-z0-9_]', re.IGNORECASE | re.UNICODE)
|
|
38
54
|
)
|
|
55
|
+
self.disable_events = disable_events is True
|
|
56
|
+
self.__handlers = []
|
|
57
|
+
self.__watcher = watcher
|
|
58
|
+
if self.__watcher is not None:
|
|
59
|
+
self.__watcher.watch_handler = self.__on_watch_event
|
|
39
60
|
|
|
40
61
|
if TYPE_CHECKING:
|
|
41
62
|
|
|
@@ -47,13 +68,76 @@ class Configuration:
|
|
|
47
68
|
"""Tell type checkers that setting any attribute to any value is allowed."""
|
|
48
69
|
...
|
|
49
70
|
|
|
71
|
+
def __del__(self) -> None:
|
|
72
|
+
self.close()
|
|
73
|
+
|
|
74
|
+
def __on_changing(self, key: str | None) -> None:
|
|
75
|
+
if self.disable_events:
|
|
76
|
+
return
|
|
77
|
+
key = self.__normalize_key(key)
|
|
78
|
+
for entry in self.__handlers:
|
|
79
|
+
entry(self, ChangeType.CHANGING, key)
|
|
80
|
+
|
|
81
|
+
def __on_changed(self, key: str | None) -> None:
|
|
82
|
+
if self.disable_events:
|
|
83
|
+
return
|
|
84
|
+
key = self.__normalize_key(key)
|
|
85
|
+
for entry in self.__handlers:
|
|
86
|
+
entry(self, ChangeType.CHANGED, key)
|
|
87
|
+
|
|
88
|
+
def __on_close(self, key: str | None) -> None:
|
|
89
|
+
if self.disable_events:
|
|
90
|
+
return
|
|
91
|
+
key = self.__normalize_key(key)
|
|
92
|
+
for entry in self.__handlers:
|
|
93
|
+
entry(self, ChangeType.CLOSED, None)
|
|
94
|
+
self.__handlers.clear()
|
|
95
|
+
|
|
96
|
+
def __on_watch_event(self, filepath: str, watch_event_type: str, configuration: Configuration | None = None) -> None:
|
|
97
|
+
if watch_event_type in ('c', 'm') and configuration is not None:
|
|
98
|
+
self.merge(configuration)
|
|
99
|
+
|
|
100
|
+
def __add_child_change_handler(self, child_key: str) -> None:
|
|
101
|
+
if self.disable_events:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
def child_change_handler(configuration: Configuration, change_type: ChangeType, key: str | None) -> None:
|
|
105
|
+
if change_type is ChangeType.CHANGING:
|
|
106
|
+
self.__on_changing(f'{child_key}.{key}')
|
|
107
|
+
elif change_type is ChangeType.CHANGED:
|
|
108
|
+
self.__on_changed(f'{child_key}.{key}')
|
|
109
|
+
child_change_handler.__dict__['_auto_wired'] = True
|
|
110
|
+
child_change_handler.__dict__['_auto_wired_child_key'] = child_key
|
|
111
|
+
child_change_handler.__dict__['_auto_wired_parent'] = self
|
|
112
|
+
self[child_key].add_change_handler(child_change_handler)
|
|
113
|
+
|
|
114
|
+
def __remove_child_change_handler(self, child_key: str) -> None:
|
|
115
|
+
child = getattr(self, child_key, None)
|
|
116
|
+
if child is None:
|
|
117
|
+
return
|
|
118
|
+
child_handlers = child.__handlers
|
|
119
|
+
for i in range(len(child_handlers) - 1, -1, -1):
|
|
120
|
+
handler = child_handlers[i]
|
|
121
|
+
d = getattr(handler, '__dict__', {})
|
|
122
|
+
if d.get('_auto_wired') and d.get('_auto_wired_child_key') == child_key:
|
|
123
|
+
child_handlers.pop(i)
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
def __normalize_key(self, key: str | None) -> str | None:
|
|
127
|
+
return None if key is None else key.replace('__', '.').replace(':', '.')
|
|
128
|
+
|
|
50
129
|
def __delitem__(self, key: str) -> None:
|
|
51
130
|
"""Delete the specified configuration key."""
|
|
52
131
|
key = key.upper()
|
|
53
132
|
k = self.__keys.get(key)
|
|
54
133
|
if k is not None:
|
|
134
|
+
value = getattr(self, k)
|
|
135
|
+
self.__on_changing(key)
|
|
136
|
+
if isinstance(value, Configuration):
|
|
137
|
+
self.__remove_child_change_handler(k)
|
|
55
138
|
delattr(self, k)
|
|
56
139
|
self.__keys.pop(key)
|
|
140
|
+
self.__on_changed(key)
|
|
57
141
|
|
|
58
142
|
def __getitem__(self, key: str) -> Any:
|
|
59
143
|
"""
|
|
@@ -320,34 +404,6 @@ class Configuration:
|
|
|
320
404
|
"""Render the configuration as a JSON string."""
|
|
321
405
|
return json.dumps(self.to_dict(), indent=None, ensure_ascii=False)
|
|
322
406
|
|
|
323
|
-
def bind(self, target: object, key: str | None = None) -> Any:
|
|
324
|
-
"""
|
|
325
|
-
Binds the configuration values into the target object.
|
|
326
|
-
|
|
327
|
-
Can optionally specify a configuration key to bind from.
|
|
328
|
-
|
|
329
|
-
:param target: The object to bind configuration data into.
|
|
330
|
-
:param key: An optional confguration key to bind to, defaults to None which binds to the configuration root.
|
|
331
|
-
:return: The original `target` object, modified in-place.
|
|
332
|
-
"""
|
|
333
|
-
if target is None:
|
|
334
|
-
raise ConfigurationException('Missing required argument: target')
|
|
335
|
-
if key is None:
|
|
336
|
-
return self.__recursive_bind(target, self)
|
|
337
|
-
else:
|
|
338
|
-
source = self.get(key)
|
|
339
|
-
if source is not None:
|
|
340
|
-
source_type = type(source)
|
|
341
|
-
if source_type in (Configuration, dict):
|
|
342
|
-
return self.__recursive_bind(target, source)
|
|
343
|
-
elif source_type is list:
|
|
344
|
-
return self.__bind_list_source(target, key, source)
|
|
345
|
-
else:
|
|
346
|
-
# Scalar source: convert to matching typed attribute(s) on target
|
|
347
|
-
return self.__bind_scalar_to_target(target, key, source)
|
|
348
|
-
else:
|
|
349
|
-
return target
|
|
350
|
-
|
|
351
407
|
def __bind_list_source(self, target: object, key: str, source: list[Any]) -> Any:
|
|
352
408
|
"""Bind a Configuration- or dict-wrapped list source to *target*."""
|
|
353
409
|
if not hasattr(target, '__class__'):
|
|
@@ -399,29 +455,70 @@ class Configuration:
|
|
|
399
455
|
# Complex type or Any — nothing we can do for a scalar source
|
|
400
456
|
return value
|
|
401
457
|
|
|
458
|
+
def bind(self, target: object, key: str | None = None) -> Any:
|
|
459
|
+
"""
|
|
460
|
+
Binds the configuration values into the target object.
|
|
461
|
+
|
|
462
|
+
Can optionally specify a configuration key to bind from.
|
|
463
|
+
|
|
464
|
+
:param target: The object to bind configuration data into.
|
|
465
|
+
:param key: An optional confguration key to bind to, defaults to None which binds to the configuration root.
|
|
466
|
+
:return: The original `target` object, modified in-place.
|
|
467
|
+
"""
|
|
468
|
+
if target is None:
|
|
469
|
+
raise ConfigurationException('Missing required argument: target')
|
|
470
|
+
if key is None:
|
|
471
|
+
return self.__recursive_bind(target, self)
|
|
472
|
+
else:
|
|
473
|
+
source = self.get(key)
|
|
474
|
+
if source is not None:
|
|
475
|
+
source_type = type(source)
|
|
476
|
+
if source_type in (Configuration, dict):
|
|
477
|
+
return self.__recursive_bind(target, source)
|
|
478
|
+
elif source_type is list:
|
|
479
|
+
return self.__bind_list_source(target, key, source)
|
|
480
|
+
else:
|
|
481
|
+
# Scalar source: convert to matching typed attribute(s) on target
|
|
482
|
+
return self.__bind_scalar_to_target(target, key, source)
|
|
483
|
+
else:
|
|
484
|
+
return target
|
|
485
|
+
|
|
486
|
+
def close(self) -> None:
|
|
487
|
+
self.__on_close(None)
|
|
488
|
+
|
|
489
|
+
def add_change_handler(self, handler: Callable[["Configuration", ChangeType, str | None], None]) -> None:
|
|
490
|
+
self.__handlers.append(handler)
|
|
491
|
+
|
|
492
|
+
def remove_change_handler(self, handler: Callable[["Configuration", ChangeType, str | None], None]) -> None:
|
|
493
|
+
for i in range(len(self.__handlers)):
|
|
494
|
+
if self.__handlers[i] is handler:
|
|
495
|
+
self.__handlers.pop(i)
|
|
496
|
+
break
|
|
497
|
+
|
|
402
498
|
def clear(self) -> None:
|
|
403
499
|
"""Clear all configuration data."""
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
500
|
+
keys = list(self.__keys.keys())
|
|
501
|
+
for uk in keys:
|
|
502
|
+
del self[uk]
|
|
407
503
|
|
|
408
504
|
@staticmethod
|
|
409
|
-
def from_dict(source: dict[str, Any], normalize: bool = False, scrubkeys: bool = False) ->
|
|
505
|
+
def from_dict(source: dict[str, Any], normalize: bool = False, scrubkeys: bool = False, disable_events: bool | None = None) -> Configuration:
|
|
410
506
|
"""
|
|
411
507
|
Construct a :py:class:`~appsettings2.Configuration` instance from the supplied dictionary `source`.
|
|
412
508
|
|
|
413
509
|
:param source: The dictionary object to populate from.
|
|
414
510
|
:param normalize: Option indicating whether or not attribute names should be normalized to upper-case on the resulting :py:class:`~appsettings2.Configuration` object, defaults to False.
|
|
415
511
|
:param scrubkeys: Option indicating whether or not attribute names should be scrubbed to be compatible with the Python lexer, defaults to False.
|
|
512
|
+
:param disable_events: Option indicating whether change events should be emitted, defaults to False.
|
|
416
513
|
:return: A :py:class:`~appsettings2.Configuration` object derived from the `source` parameter.
|
|
417
514
|
"""
|
|
418
515
|
config: Configuration = Configuration(
|
|
419
|
-
normalize=normalize, scrubkeys=scrubkeys)
|
|
516
|
+
normalize=normalize, scrubkeys=scrubkeys, disable_events=disable_events)
|
|
420
517
|
for kvp in source.items():
|
|
421
518
|
v = kvp[1]
|
|
422
519
|
if issubclass(type(v), dict):
|
|
423
520
|
v = Configuration.from_dict(
|
|
424
|
-
v, normalize=normalize, scrubkeys=scrubkeys)
|
|
521
|
+
v, normalize=normalize, scrubkeys=scrubkeys, disable_events=disable_events)
|
|
425
522
|
config.set(kvp[0], v)
|
|
426
523
|
return config
|
|
427
524
|
|
|
@@ -465,6 +562,19 @@ class Configuration:
|
|
|
465
562
|
"""Get a list of all configuration keys."""
|
|
466
563
|
return list(self.__keys.values())
|
|
467
564
|
|
|
565
|
+
def merge(self, configuration: Configuration) -> None:
|
|
566
|
+
"""
|
|
567
|
+
Performs a shallow merge of the provided configuration object.
|
|
568
|
+
|
|
569
|
+
Keys present in ``configuration`` that do not already exist in ``self`` (or have differing values) are written to ``self``.
|
|
570
|
+
|
|
571
|
+
:param configuration: The object to merge.
|
|
572
|
+
"""
|
|
573
|
+
_sentinel = object()
|
|
574
|
+
for key in configuration.keys():
|
|
575
|
+
if self.get(key, _sentinel) != configuration[key]:
|
|
576
|
+
self[key] = configuration[key]
|
|
577
|
+
|
|
468
578
|
def pop(self, key: str) -> Any:
|
|
469
579
|
"""Delete a specific configuration key."""
|
|
470
580
|
value = self[key]
|
|
@@ -481,7 +591,11 @@ class Configuration:
|
|
|
481
591
|
if self.__normalize:
|
|
482
592
|
key = key.upper()
|
|
483
593
|
parts = key.replace(':', '__').split('__')
|
|
594
|
+
full_key_for_events = key.replace('__', '.').replace(':', '.')
|
|
595
|
+
self.__on_changing(full_key_for_events)
|
|
484
596
|
o = self
|
|
597
|
+
|
|
598
|
+
# Build intermediate hierarchy without emitting events
|
|
485
599
|
for i in range(len(parts) - 1):
|
|
486
600
|
if o == self:
|
|
487
601
|
k = self.__keys.get(parts[i].upper())
|
|
@@ -490,6 +604,7 @@ class Configuration:
|
|
|
490
604
|
self.__key_scrub_re is None))
|
|
491
605
|
setattr(o, self.__scrub_key(parts[i]), c)
|
|
492
606
|
self.__keys[parts[i].upper()] = parts[i]
|
|
607
|
+
o.__add_child_change_handler(parts[i])
|
|
493
608
|
o = c
|
|
494
609
|
else:
|
|
495
610
|
o = getattr(self, self.__scrub_key(k))
|
|
@@ -501,6 +616,8 @@ class Configuration:
|
|
|
501
616
|
o = c
|
|
502
617
|
else:
|
|
503
618
|
o = o.get(parts[i])
|
|
619
|
+
|
|
620
|
+
# Convert dict/list values
|
|
504
621
|
vtype = type(value)
|
|
505
622
|
if issubclass(vtype, dict):
|
|
506
623
|
value = Configuration.from_dict(
|
|
@@ -514,6 +631,7 @@ class Configuration:
|
|
|
514
631
|
else:
|
|
515
632
|
l.append(e)
|
|
516
633
|
value = l
|
|
634
|
+
|
|
517
635
|
key = parts[-1]
|
|
518
636
|
if o == self:
|
|
519
637
|
k = self.__keys.get(key.upper())
|
|
@@ -522,9 +640,14 @@ class Configuration:
|
|
|
522
640
|
else:
|
|
523
641
|
self.__keys[key.upper()] = key
|
|
524
642
|
setattr(self, self.__scrub_key(key), value)
|
|
643
|
+
if isinstance(value, Configuration):
|
|
644
|
+
self.__add_child_change_handler(key)
|
|
525
645
|
else:
|
|
526
646
|
o.set(key, value)
|
|
527
647
|
|
|
648
|
+
self.__on_changed(full_key_for_events)
|
|
649
|
+
|
|
650
|
+
|
|
528
651
|
def to_dict(self) -> dict[str, Any]:
|
|
529
652
|
"""
|
|
530
653
|
Create a dictionary from the `Configuration` object.
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2025 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
from typing import Any, Optional, TypeAlias
|
|
5
|
+
|
|
4
6
|
from .Configuration import Configuration
|
|
5
7
|
from .ConfigurationException import ConfigurationException
|
|
6
8
|
from .providers import (
|
|
@@ -11,7 +13,6 @@ from .providers import (
|
|
|
11
13
|
TomlConfigurationProvider,
|
|
12
14
|
YamlConfigurationProvider,
|
|
13
15
|
)
|
|
14
|
-
from typing import Optional, TypeAlias
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
FileDescriptor: TypeAlias = int
|
|
@@ -22,19 +23,25 @@ class ConfigurationBuilder:
|
|
|
22
23
|
Build a :py:class:`~appsettings2.Configuration` object from one or more :py:class:`~appsettings2.providers.ConfigurationProvider` instances.
|
|
23
24
|
"""
|
|
24
25
|
|
|
26
|
+
__disable_events: bool | None
|
|
25
27
|
__normalize: bool
|
|
26
28
|
__providers: list[ConfigurationProvider]
|
|
29
|
+
__watcher: Any
|
|
27
30
|
|
|
28
|
-
def __init__(self, normalize: bool = False, scrubkeys: bool = False) -> None:
|
|
31
|
+
def __init__(self, normalize: bool = False, scrubkeys: bool = False, disable_events: bool | None = None, watcher: Any = None) -> None:
|
|
29
32
|
"""
|
|
30
33
|
Initialize *ConfigurationBuilder* instance.
|
|
31
34
|
|
|
32
35
|
:param normalize: Option indicating whether or not attribute names should be normalized to upper-case on the resulting :py:class:`~appsettings2.Configuration` object, defaults to False.
|
|
33
36
|
:param scrubkeys: Option indicating whether or not attribute names should be scrubbed to be compatible with the Python lexer, defaults to False.
|
|
37
|
+
:param disable_events: Option indicating whether change events should be emitted, defaults to None.
|
|
38
|
+
:param watcher: Optional :py:class:`~appsettings2.ConfigurationWatcher` to associate with the built configuration.
|
|
34
39
|
"""
|
|
40
|
+
self.__disable_events = disable_events
|
|
35
41
|
self.__normalize = normalize
|
|
36
42
|
self.__scrubkeys = scrubkeys
|
|
37
43
|
self.__providers = []
|
|
44
|
+
self.__watcher = watcher
|
|
38
45
|
|
|
39
46
|
def add_provider(self, provider: ConfigurationProvider) -> 'ConfigurationBuilder':
|
|
40
47
|
"""
|
|
@@ -128,16 +135,53 @@ class ConfigurationBuilder:
|
|
|
128
135
|
addYaml = add_yaml
|
|
129
136
|
"""⚠️ DEPRECATED: use ``add_yaml(...)`` instead."""
|
|
130
137
|
|
|
131
|
-
def
|
|
138
|
+
def disable_events(self) -> 'ConfigurationBuilder':
|
|
139
|
+
"""
|
|
140
|
+
Disable change events for the built :py:class:`~appsettings2.Configuration`.
|
|
141
|
+
|
|
142
|
+
:return: Returns :py:class:`~appsettings2.ConfigurationBuilder` for method chaining.
|
|
143
|
+
"""
|
|
144
|
+
self.__disable_events = True
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def disableEvents(self) -> 'ConfigurationBuilder':
|
|
148
|
+
"""⚠️ DEPRECATED: use ``disable_events(...)`` instead."""
|
|
149
|
+
return self.disable_events()
|
|
150
|
+
|
|
151
|
+
def enable_watcher(self) -> 'ConfigurationBuilder':
|
|
152
|
+
"""
|
|
153
|
+
Create and assign a :py:class:`~appsettings2.ConfigurationWatcher` instance to the builder.
|
|
154
|
+
|
|
155
|
+
Only creates a new watcher if ``self.__watcher`` is ``None``.
|
|
156
|
+
|
|
157
|
+
:return: Returns :py:class:`~appsettings2.ConfigurationBuilder` for method chaining.
|
|
158
|
+
"""
|
|
159
|
+
if self.__watcher is None:
|
|
160
|
+
from .ConfigurationWatcher import ConfigurationWatcher
|
|
161
|
+
self.__watcher = ConfigurationWatcher()
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def build(self, disable_events: bool | None = None, watcher: Any = None) -> Configuration:
|
|
132
165
|
"""
|
|
133
166
|
Builds a `Configuration` object using the providers which have been added to the builder.
|
|
134
167
|
|
|
168
|
+
:param disable_events: Option indicating whether change events should be emitted, defaults to None (uses value from ``__init__``).
|
|
169
|
+
:param watcher: Optional :py:class:`~appsettings2.ConfigurationWatcher`, defaults to None (uses value from ``__init__``).
|
|
135
170
|
:return: A `Configuration` object, populated with configuration data.
|
|
136
171
|
"""
|
|
172
|
+
effective_watcher = watcher if watcher is not None else self.__watcher
|
|
137
173
|
configuration = Configuration(
|
|
138
|
-
normalize=self.__normalize,
|
|
174
|
+
normalize=self.__normalize,
|
|
175
|
+
scrubkeys=self.__scrubkeys,
|
|
176
|
+
disable_events=disable_events if disable_events is not None else self.__disable_events,
|
|
177
|
+
watcher=effective_watcher)
|
|
139
178
|
for provider in self.__providers:
|
|
140
179
|
provider.populate_configuration(configuration)
|
|
180
|
+
if effective_watcher is not None:
|
|
181
|
+
for provider in self.__providers:
|
|
182
|
+
fp = getattr(provider, 'filepath', None)
|
|
183
|
+
if fp is not None:
|
|
184
|
+
effective_watcher.add_watch(fp)
|
|
141
185
|
return configuration
|
|
142
186
|
|
|
143
187
|
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: © 2026 Shaun Wilson
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import Callable, TypeAlias, TypedDict
|
|
12
|
+
|
|
13
|
+
from .Configuration import Configuration
|
|
14
|
+
from .providers import (
|
|
15
|
+
JsonConfigurationProvider,
|
|
16
|
+
TomlConfigurationProvider,
|
|
17
|
+
YamlConfigurationProvider,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WatchEventType(str, Enum):
|
|
22
|
+
"""The type of file system event detected during polling.
|
|
23
|
+
|
|
24
|
+
A ``str``-backed ``Enum`` with compact single-character values.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
CREATED = 'c'
|
|
28
|
+
"""The file was created or appeared after being deleted."""
|
|
29
|
+
|
|
30
|
+
MODIFIED = 'm'
|
|
31
|
+
"""The file was modified (mtime or file size changed)."""
|
|
32
|
+
|
|
33
|
+
DELETED = 'd'
|
|
34
|
+
"""The file was deleted or disappeared."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WatchState(TypedDict):
|
|
38
|
+
"""Internal state dictionary for a single watched file.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
path: The fully resolved (canonical) file path.
|
|
42
|
+
size: Last known file size in bytes, or ``None`` if not yet polled.
|
|
43
|
+
mtime: Last known modification time, or ``None`` if not yet polled.
|
|
44
|
+
state: One of ``'active'``, ``'deleted'``, or ``'pending'``.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
path: str
|
|
48
|
+
size: int | None
|
|
49
|
+
mtime: float | None
|
|
50
|
+
state: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
WatchEventHandler: TypeAlias = (
|
|
54
|
+
Callable[[str, WatchEventType, Configuration | None], None] | None
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ConfigurationWatcher:
|
|
59
|
+
"""Monitors configuration files on disk for file system changes.
|
|
60
|
+
|
|
61
|
+
Uses periodic polling with metadata comparison (mtime + file size) rather than
|
|
62
|
+
OS-specific file system APIs. A background daemon thread manages the polling
|
|
63
|
+
loop and spawns/terminates automatically based on active watches.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
__interval: float
|
|
67
|
+
__handler: WatchEventHandler
|
|
68
|
+
__handler_lock: threading.Lock
|
|
69
|
+
__lock: threading.Lock
|
|
70
|
+
__logger: logging.Logger
|
|
71
|
+
__running: bool
|
|
72
|
+
__thread: threading.Thread | None
|
|
73
|
+
__watches: dict[str, WatchState]
|
|
74
|
+
|
|
75
|
+
def __init__(self, interval_seconds: float | None = None) -> None:
|
|
76
|
+
"""Initialize the configuration file watcher.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
interval_seconds: Polling interval in seconds. Defaults to ``60.0``.
|
|
80
|
+
"""
|
|
81
|
+
self.__interval = interval_seconds if interval_seconds is not None else 60.0
|
|
82
|
+
self.__watches = {}
|
|
83
|
+
self.__thread = None
|
|
84
|
+
self.__running = False
|
|
85
|
+
self.__lock = threading.Lock()
|
|
86
|
+
self.__handler = None
|
|
87
|
+
self.__handler_lock = threading.Lock()
|
|
88
|
+
self.__logger = logging.getLogger(__name__)
|
|
89
|
+
|
|
90
|
+
def add_watch(self, filepath: str) -> None:
|
|
91
|
+
"""Add a file path to be watched for changes.
|
|
92
|
+
|
|
93
|
+
The path is normalised to a fully-qualified (canonical) path via
|
|
94
|
+
``os.path.realpath``. Duplicate resolved paths are silently ignored.
|
|
95
|
+
The polling thread is started on the first ``add_watch`` call.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
filepath: The file system path to watch.
|
|
99
|
+
"""
|
|
100
|
+
resolved = os.path.realpath(filepath)
|
|
101
|
+
with self.__lock:
|
|
102
|
+
is_first_watch = len(self.__watches) == 0
|
|
103
|
+
if resolved in self.__watches:
|
|
104
|
+
return
|
|
105
|
+
self.__watches[resolved] = {
|
|
106
|
+
'path': resolved,
|
|
107
|
+
'size': None,
|
|
108
|
+
'mtime': None,
|
|
109
|
+
'state': 'pending',
|
|
110
|
+
}
|
|
111
|
+
if is_first_watch:
|
|
112
|
+
self.__start_thread()
|
|
113
|
+
|
|
114
|
+
def remove_watch(self, filepath: str) -> None:
|
|
115
|
+
"""Remove a previously added watch.
|
|
116
|
+
|
|
117
|
+
Silently does nothing if the watch does not exist. The polling thread
|
|
118
|
+
drains and exits when the last watch is removed.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
filepath: The file system path whose watch should be removed.
|
|
122
|
+
"""
|
|
123
|
+
resolved = os.path.realpath(filepath)
|
|
124
|
+
with self.__lock:
|
|
125
|
+
if resolved not in self.__watches:
|
|
126
|
+
return
|
|
127
|
+
del self.__watches[resolved]
|
|
128
|
+
if len(self.__watches) == 0 and self.__thread is not None:
|
|
129
|
+
self.__drain_thread()
|
|
130
|
+
|
|
131
|
+
def list_watches(self) -> list[str]:
|
|
132
|
+
"""Return a fresh list of all currently watched file paths.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
A list of canonical (resolved) file path strings.
|
|
136
|
+
"""
|
|
137
|
+
with self.__lock:
|
|
138
|
+
return [e['path'] for e in self.__watches.values()]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def watch_handler(self) -> WatchEventHandler:
|
|
142
|
+
"""The callback invoked for file system events.
|
|
143
|
+
|
|
144
|
+
The getter returns ``None`` if no handler has been set. The setter
|
|
145
|
+
accepts ``None`` to clear the handler.
|
|
146
|
+
|
|
147
|
+
The handler reference is captured under a lock and then invoked
|
|
148
|
+
outside the lock to avoid potential deadlocks with external calls
|
|
149
|
+
from within the handler (e.g. ``add_watch`` / ``remove_watch``).
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A callable of the form ``Callable[[str, WatchEventType, Configuration], None]``,
|
|
153
|
+
or ``None`` if no handler is set.
|
|
154
|
+
"""
|
|
155
|
+
with self.__handler_lock:
|
|
156
|
+
return self.__handler
|
|
157
|
+
|
|
158
|
+
@watch_handler.setter
|
|
159
|
+
def watch_handler(self, handler: WatchEventHandler) -> None:
|
|
160
|
+
"""Set the event handler callback.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
handler: A callable of the form ``Callable[[str, WatchEventType, Configuration], None]``,
|
|
164
|
+
or ``None`` to clear.
|
|
165
|
+
"""
|
|
166
|
+
with self.__handler_lock:
|
|
167
|
+
self.__handler = handler
|
|
168
|
+
|
|
169
|
+
def __start_thread(self) -> None:
|
|
170
|
+
"""Start the background polling thread.
|
|
171
|
+
|
|
172
|
+
Called internally on the first ``add_watch``. The thread is a
|
|
173
|
+
daemon thread so it does not prevent process exit.
|
|
174
|
+
"""
|
|
175
|
+
self.__running = True
|
|
176
|
+
self.__thread = threading.Thread(target=self.__poll_loop, daemon=True)
|
|
177
|
+
self.__thread.start()
|
|
178
|
+
|
|
179
|
+
def __drain_thread(self) -> None:
|
|
180
|
+
"""Stop the background polling thread and wait for it to exit.
|
|
181
|
+
|
|
182
|
+
Sets ``__running`` to ``False``, then joins the thread with a
|
|
183
|
+
timeout of ``self.__interval + 1`` seconds.
|
|
184
|
+
"""
|
|
185
|
+
self.__running = False
|
|
186
|
+
if self.__thread is not None:
|
|
187
|
+
self.__thread.join(timeout=self.__interval + 1)
|
|
188
|
+
if self.__thread.is_alive():
|
|
189
|
+
self.__logger.warning(
|
|
190
|
+
'Watcher thread did not drain within %s seconds.',
|
|
191
|
+
self.__interval + 1,
|
|
192
|
+
)
|
|
193
|
+
self.__thread = None
|
|
194
|
+
|
|
195
|
+
def __poll_loop(self) -> None:
|
|
196
|
+
"""Background thread entry point — continuous polling loop."""
|
|
197
|
+
while self.__running:
|
|
198
|
+
self.__poll_once()
|
|
199
|
+
try:
|
|
200
|
+
time.sleep(self.__interval)
|
|
201
|
+
except KeyboardInterrupt:
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
def __poll_once(self) -> None:
|
|
205
|
+
"""Execute a single poll cycle over all watches.
|
|
206
|
+
|
|
207
|
+
A snapshot of watch states is taken at the start of the cycle so
|
|
208
|
+
that concurrent ``add_watch``/``remove_watch`` calls do not
|
|
209
|
+
interfere with the current iteration. All file I/O and parsing
|
|
210
|
+
errors are logged at ERROR level and do not crash the thread.
|
|
211
|
+
The watch state is updated directly in ``self.__watches`` after
|
|
212
|
+
each event is processed.
|
|
213
|
+
"""
|
|
214
|
+
with self.__lock:
|
|
215
|
+
paths = list(self.__watches.keys())
|
|
216
|
+
|
|
217
|
+
if not paths:
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
handler_ref = None
|
|
221
|
+
with self.__handler_lock:
|
|
222
|
+
handler_ref = self.__handler
|
|
223
|
+
|
|
224
|
+
for path in paths:
|
|
225
|
+
with self.__lock:
|
|
226
|
+
if path not in self.__watches:
|
|
227
|
+
continue
|
|
228
|
+
state = self.__watches[path]
|
|
229
|
+
prev_state = state['state']
|
|
230
|
+
prev_size = state['size']
|
|
231
|
+
prev_mtime = state['mtime']
|
|
232
|
+
|
|
233
|
+
file_exists = os.path.isfile(path)
|
|
234
|
+
|
|
235
|
+
if file_exists and prev_state in ('deleted', 'pending'):
|
|
236
|
+
self.__on_created(path, state, handler_ref)
|
|
237
|
+
elif file_exists and prev_state == 'active':
|
|
238
|
+
if prev_size is None or prev_mtime is None:
|
|
239
|
+
try:
|
|
240
|
+
stat = os.stat(path)
|
|
241
|
+
state['size'] = stat.st_size
|
|
242
|
+
state['mtime'] = stat.st_mtime
|
|
243
|
+
except OSError:
|
|
244
|
+
state['state'] = 'deleted'
|
|
245
|
+
self.__on_deleted(path, handler_ref)
|
|
246
|
+
else:
|
|
247
|
+
try:
|
|
248
|
+
stat = os.stat(path)
|
|
249
|
+
if stat.st_size != prev_size or stat.st_mtime != prev_mtime:
|
|
250
|
+
self.__on_modified(path, state, handler_ref)
|
|
251
|
+
else:
|
|
252
|
+
state['size'] = stat.st_size
|
|
253
|
+
state['mtime'] = stat.st_mtime
|
|
254
|
+
except OSError:
|
|
255
|
+
state['state'] = 'deleted'
|
|
256
|
+
self.__on_deleted(path, handler_ref)
|
|
257
|
+
elif not file_exists and prev_state == 'active':
|
|
258
|
+
state['state'] = 'deleted'
|
|
259
|
+
self.__on_deleted(path, handler_ref)
|
|
260
|
+
|
|
261
|
+
def __on_created(
|
|
262
|
+
self, path: str, state: WatchState, handler: WatchEventHandler,
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Handle a CREATED event.
|
|
265
|
+
|
|
266
|
+
Loads the configuration from the newly appeared file, updates the
|
|
267
|
+
watch state to ``'active'``, and fires the handler.
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
path: The canonical file path.
|
|
271
|
+
state: The watch state dictionary (updated in place, already in ``self.__watches``).
|
|
272
|
+
handler: The handler callback reference (local copy).
|
|
273
|
+
"""
|
|
274
|
+
if handler is None:
|
|
275
|
+
return
|
|
276
|
+
config = self.__load_configuration(path)
|
|
277
|
+
if config is not None:
|
|
278
|
+
state['state'] = 'active'
|
|
279
|
+
try:
|
|
280
|
+
stat = os.stat(path)
|
|
281
|
+
state['size'] = stat.st_size
|
|
282
|
+
state['mtime'] = stat.st_mtime
|
|
283
|
+
except OSError:
|
|
284
|
+
pass
|
|
285
|
+
handler(path, WatchEventType.CREATED, config)
|
|
286
|
+
|
|
287
|
+
def __on_modified(
|
|
288
|
+
self, path: str, state: WatchState, handler: WatchEventHandler,
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Handle a MODIFIED event.
|
|
291
|
+
|
|
292
|
+
Loads the configuration from the modified file and fires the handler.
|
|
293
|
+
Updates the watch state only if the configuration was successfully
|
|
294
|
+
loaded.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
path: The canonical file path.
|
|
298
|
+
state: The watch state dictionary (updated in place, already in ``self.__watches``).
|
|
299
|
+
handler: The handler callback reference (local copy).
|
|
300
|
+
"""
|
|
301
|
+
if handler is None:
|
|
302
|
+
return
|
|
303
|
+
config = self.__load_configuration(path)
|
|
304
|
+
if config is not None:
|
|
305
|
+
try:
|
|
306
|
+
stat = os.stat(path)
|
|
307
|
+
state['size'] = stat.st_size
|
|
308
|
+
state['mtime'] = stat.st_mtime
|
|
309
|
+
except OSError:
|
|
310
|
+
pass
|
|
311
|
+
handler(path, WatchEventType.MODIFIED, config)
|
|
312
|
+
|
|
313
|
+
def __on_deleted(self, path: str, handler: WatchEventHandler) -> None:
|
|
314
|
+
"""Handle a DELETED event.
|
|
315
|
+
|
|
316
|
+
Fires the handler with ``None`` as the configuration parameter since
|
|
317
|
+
the file cannot be parsed.
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
path: The canonical file path.
|
|
321
|
+
handler: The handler callback reference (local copy).
|
|
322
|
+
"""
|
|
323
|
+
if handler is None:
|
|
324
|
+
return
|
|
325
|
+
handler(path, WatchEventType.DELETED, None)
|
|
326
|
+
|
|
327
|
+
def __load_configuration(self, path: str) -> Configuration | None:
|
|
328
|
+
"""Load a Configuration from a file based on its extension.
|
|
329
|
+
|
|
330
|
+
Supported extensions: ``.json``, ``.toml``, ``.yaml`` / ``.yml``.
|
|
331
|
+
Unrecognised extensions cause an error to be logged and ``None``
|
|
332
|
+
is returned. Parsing errors and I/O errors are logged and return
|
|
333
|
+
``None`` without crashing.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
path: The canonical file path to load.
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
A populated ``Configuration`` instance, or ``None`` if the
|
|
340
|
+
file cannot be loaded.
|
|
341
|
+
"""
|
|
342
|
+
ext = os.path.splitext(path)[1].lower()
|
|
343
|
+
try:
|
|
344
|
+
configuration = Configuration()
|
|
345
|
+
if ext == '.json':
|
|
346
|
+
JsonConfigurationProvider(
|
|
347
|
+
filepath=path, required=False,
|
|
348
|
+
).populate_configuration(configuration)
|
|
349
|
+
elif ext == '.toml':
|
|
350
|
+
TomlConfigurationProvider(
|
|
351
|
+
filepath=path, required=False,
|
|
352
|
+
).populate_configuration(configuration)
|
|
353
|
+
elif ext in ('.yaml', '.yml'):
|
|
354
|
+
YamlConfigurationProvider(
|
|
355
|
+
filepath=path, required=False,
|
|
356
|
+
).populate_configuration(configuration)
|
|
357
|
+
else:
|
|
358
|
+
self.__logger.error(
|
|
359
|
+
'Cannot determine format for watched file %s '
|
|
360
|
+
'(extension \'%s\'). Skipping.',
|
|
361
|
+
path,
|
|
362
|
+
ext,
|
|
363
|
+
)
|
|
364
|
+
return None
|
|
365
|
+
return configuration
|
|
366
|
+
except Exception as e:
|
|
367
|
+
self.__logger.error(
|
|
368
|
+
'Error loading configuration from %s: %s. Skipping update.',
|
|
369
|
+
path,
|
|
370
|
+
e,
|
|
371
|
+
)
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
__all__ = ['ConfigurationWatcher', 'WatchEventType']
|
|
@@ -2,20 +2,24 @@
|
|
|
2
2
|
# SPDX-FileCopyrightText: © 2025 Shaun Wilson
|
|
3
3
|
# SPDX-License-Identifier: MIT
|
|
4
4
|
|
|
5
|
-
from .Configuration import Configuration
|
|
5
|
+
from .Configuration import Configuration, ChangeType
|
|
6
6
|
from .ConfigurationBuilder import ConfigurationBuilder
|
|
7
|
+
from .ConfigurationWatcher import ConfigurationWatcher, WatchEventType
|
|
7
8
|
from .ConfigurationException import ConfigurationException
|
|
8
9
|
from .helpers import get_configuration, getConfiguration
|
|
9
10
|
from . import helpers
|
|
10
11
|
from . import providers
|
|
11
12
|
|
|
12
13
|
|
|
13
|
-
__version__ = '2.0
|
|
14
|
-
__commit__ = '
|
|
14
|
+
__version__ = '2.1.0'
|
|
15
|
+
__commit__ = 'a6890cf'
|
|
15
16
|
__all__ = [
|
|
16
17
|
'__version__', '__commit__',
|
|
17
18
|
'Configuration',
|
|
19
|
+
'ChangeType',
|
|
18
20
|
'ConfigurationBuilder',
|
|
21
|
+
'ConfigurationWatcher',
|
|
22
|
+
'WatchEventType',
|
|
19
23
|
'ConfigurationException',
|
|
20
24
|
'get_configuration',
|
|
21
25
|
'helpers',
|
|
@@ -17,7 +17,8 @@ def get_configuration(
|
|
|
17
17
|
cli: bool = True,
|
|
18
18
|
environment: bool = True,
|
|
19
19
|
variations: Optional[list[str | None]] = ['', 'prod', 'production', 'stage', 'staging', 'qa', 'dev', 'development', 'local'],
|
|
20
|
-
env_prefix: str | None = None
|
|
20
|
+
env_prefix: str | None = None,
|
|
21
|
+
disable_events: bool | None = None
|
|
21
22
|
) -> Configuration:
|
|
22
23
|
"""
|
|
23
24
|
Get a ``Configuration`` instance by loading from a set of well-known providers and their variations.
|
|
@@ -30,6 +31,7 @@ def get_configuration(
|
|
|
30
31
|
:param environment: ``True`` to load configuration from process Environment, defaults to True
|
|
31
32
|
:param variations: A set of variations to apply to use with file-based Configuration Providers, defaults to ['', 'prod', 'production', 'stage', 'staging', 'qa', 'dev', 'development', 'local']
|
|
32
33
|
:param env_prefix: (OPTIONAL) When specified, only env vars having the specified prefix are bound as configuration settings, the prefix is stripped from the resulting setting name.
|
|
34
|
+
:param disable_events: Option indicating whether change events should be emitted, defaults to False.
|
|
33
35
|
:return: An ``appsettings2.Configuration`` instance.
|
|
34
36
|
"""
|
|
35
37
|
if type(basename) is pathlib.Path or type(basename) is pathlib.PosixPath:
|
|
@@ -48,7 +50,7 @@ def get_configuration(
|
|
|
48
50
|
builder.add_command_line()
|
|
49
51
|
if environment:
|
|
50
52
|
builder.add_environment(required_prefix=env_prefix)
|
|
51
|
-
return builder.build()
|
|
53
|
+
return builder.build(disable_events=disable_events)
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
getConfiguration = get_configuration # noqa: N816
|
{appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/ConfigurationProvider.py
RENAMED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2025 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
from ..Configuration import Configuration
|
|
5
4
|
from abc import ABC, abstractmethod
|
|
6
5
|
from typing import final
|
|
7
6
|
|
|
7
|
+
from ..Configuration import Configuration
|
|
8
|
+
|
|
8
9
|
|
|
9
10
|
class ConfigurationProvider(ABC):
|
|
10
11
|
"""The abstract base class which all Configuration Providers implement."""
|
{appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/JsonConfigurationProvider.py
RENAMED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
|
|
4
4
|
import json as _json
|
|
5
5
|
import os
|
|
6
|
-
from typing import Optional, TypeAlias
|
|
6
|
+
from typing import Any, Optional, TypeAlias
|
|
7
|
+
|
|
8
|
+
from .ConfigurationProvider import ConfigurationProvider
|
|
7
9
|
from ..Configuration import Configuration
|
|
8
10
|
from ..ConfigurationException import ConfigurationException
|
|
9
|
-
from .ConfigurationProvider import ConfigurationProvider
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
FileDescriptor: TypeAlias = int
|
|
@@ -15,7 +16,8 @@ FileDescriptor: TypeAlias = int
|
|
|
15
16
|
class JsonConfigurationProvider(ConfigurationProvider):
|
|
16
17
|
"""A ``ConfigurationProvider`` that populates configuration data from JSON."""
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
__filepath: str | None
|
|
20
|
+
__obj: dict[str, Any]
|
|
19
21
|
|
|
20
22
|
def __init__(self, filepath: Optional[str] = None, json: Optional[str] = None, fd: Optional[FileDescriptor] = None, required: bool = True) -> None:
|
|
21
23
|
"""
|
|
@@ -28,6 +30,7 @@ class JsonConfigurationProvider(ConfigurationProvider):
|
|
|
28
30
|
:param fd: Optional file descriptor (int) to be used as a configuration source, defaults to None.
|
|
29
31
|
:param required: Optional parameter indicating whether the configuration source will raise `ConfigurationException` if the specified configuration source is missing, defaults to True.
|
|
30
32
|
"""
|
|
33
|
+
self.__filepath = filepath
|
|
31
34
|
obj = None
|
|
32
35
|
try:
|
|
33
36
|
if filepath:
|
|
@@ -44,7 +47,7 @@ class JsonConfigurationProvider(ConfigurationProvider):
|
|
|
44
47
|
finally:
|
|
45
48
|
self.__obj = {} if obj is None else obj
|
|
46
49
|
|
|
47
|
-
def __populate_recursive(self, configuration: Configuration, prefix: str, o: dict) -> None:
|
|
50
|
+
def __populate_recursive(self, configuration: Configuration, prefix: str, o: dict[str, Any]) -> None:
|
|
48
51
|
for kvp in o.items():
|
|
49
52
|
if isinstance(kvp[1], dict):
|
|
50
53
|
self.__populate_recursive(configuration, f'{prefix}__{kvp[0]}', kvp[1])
|
|
@@ -60,5 +63,10 @@ class JsonConfigurationProvider(ConfigurationProvider):
|
|
|
60
63
|
else:
|
|
61
64
|
configuration.set(f'{kvp[0]}', kvp[1])
|
|
62
65
|
|
|
66
|
+
@property
|
|
67
|
+
def filepath(self) -> str | None:
|
|
68
|
+
"""The file path used to initialize this provider, or ``None``."""
|
|
69
|
+
return self.__filepath
|
|
70
|
+
|
|
63
71
|
|
|
64
72
|
__all__ = ['JsonConfigurationProvider']
|
{appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/TomlConfigurationProvider.py
RENAMED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2025 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
+
import os
|
|
5
|
+
import tomllib as _toml
|
|
6
|
+
from typing import Any, Optional, TypeAlias
|
|
7
|
+
|
|
4
8
|
from .ConfigurationProvider import ConfigurationProvider
|
|
5
9
|
from ..Configuration import Configuration
|
|
6
10
|
from ..ConfigurationException import ConfigurationException
|
|
7
|
-
import os
|
|
8
|
-
import tomllib as _toml
|
|
9
|
-
from typing import Optional, TypeAlias
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
FileDescriptor: TypeAlias = int
|
|
@@ -15,7 +16,8 @@ FileDescriptor: TypeAlias = int
|
|
|
15
16
|
class TomlConfigurationProvider(ConfigurationProvider):
|
|
16
17
|
"""A ``ConfigurationProvider`` that populates configuration data from TOML."""
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
__filepath: str | None
|
|
20
|
+
__obj: dict[str, Any]
|
|
19
21
|
|
|
20
22
|
def __init__(self, filepath: Optional[str] = None, toml: Optional[str] = None, fd: Optional[FileDescriptor] = None, required: bool = True) -> None:
|
|
21
23
|
"""
|
|
@@ -28,6 +30,7 @@ class TomlConfigurationProvider(ConfigurationProvider):
|
|
|
28
30
|
:param fd: Optional file descriptor (int) to be used as a configuration source, defaults to None.
|
|
29
31
|
:param required: Optional parameter indicating whether the configuration source will raise `ConfigurationException` if the specified configuration source is missing, defaults to True.
|
|
30
32
|
"""
|
|
33
|
+
self.__filepath = filepath
|
|
31
34
|
obj = None
|
|
32
35
|
try:
|
|
33
36
|
if filepath:
|
|
@@ -44,7 +47,7 @@ class TomlConfigurationProvider(ConfigurationProvider):
|
|
|
44
47
|
finally:
|
|
45
48
|
self.__obj = {} if obj is None else obj
|
|
46
49
|
|
|
47
|
-
def __populate_recursive(self, configuration: Configuration, prefix: str, o: dict) -> None:
|
|
50
|
+
def __populate_recursive(self, configuration: Configuration, prefix: str, o: dict[str, Any]) -> None:
|
|
48
51
|
for kvp in o.items():
|
|
49
52
|
if isinstance(kvp[1], dict):
|
|
50
53
|
self.__populate_recursive(configuration, f'{prefix}__{kvp[0]}', kvp[1])
|
|
@@ -60,5 +63,10 @@ class TomlConfigurationProvider(ConfigurationProvider):
|
|
|
60
63
|
else:
|
|
61
64
|
configuration.set(f'{kvp[0]}', kvp[1])
|
|
62
65
|
|
|
66
|
+
@property
|
|
67
|
+
def filepath(self) -> str | None:
|
|
68
|
+
"""The file path used to initialize this provider, or ``None``."""
|
|
69
|
+
return self.__filepath
|
|
70
|
+
|
|
63
71
|
|
|
64
72
|
__all__ = ['TomlConfigurationProvider']
|
{appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/YamlConfigurationProvider.py
RENAMED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: © 2025 Shaun Wilson
|
|
2
2
|
# SPDX-License-Identifier: MIT
|
|
3
3
|
|
|
4
|
-
from .ConfigurationProvider import ConfigurationProvider
|
|
5
|
-
from ..Configuration import Configuration
|
|
6
|
-
from ..ConfigurationException import ConfigurationException
|
|
7
4
|
from io import StringIO
|
|
8
5
|
import os
|
|
9
|
-
from typing import Optional, TypeAlias
|
|
10
6
|
import yaml as _yaml
|
|
7
|
+
from typing import Any, Optional, TypeAlias
|
|
8
|
+
|
|
9
|
+
from .ConfigurationProvider import ConfigurationProvider
|
|
10
|
+
from ..Configuration import Configuration
|
|
11
|
+
from ..ConfigurationException import ConfigurationException
|
|
11
12
|
|
|
12
13
|
FileDescriptor: TypeAlias = int
|
|
13
14
|
|
|
@@ -15,7 +16,8 @@ FileDescriptor: TypeAlias = int
|
|
|
15
16
|
class YamlConfigurationProvider(ConfigurationProvider):
|
|
16
17
|
"""A ``ConfigurationProvider`` that populates configuration data from YAML."""
|
|
17
18
|
|
|
18
|
-
|
|
19
|
+
__filepath: str | None
|
|
20
|
+
__obj: dict[str, Any]
|
|
19
21
|
|
|
20
22
|
def __init__(self, filepath: Optional[str] = None, yaml: Optional[str] = None, fd: Optional[FileDescriptor] = None, required: bool = True) -> None:
|
|
21
23
|
"""
|
|
@@ -28,6 +30,7 @@ class YamlConfigurationProvider(ConfigurationProvider):
|
|
|
28
30
|
:param fd: Optional file descriptor (int) to be used as a configuration source, defaults to None.
|
|
29
31
|
:param required: Optional parameter indicating whether the configuration source will raise `ConfigurationException` if the specified configuration source is missing, defaults to True.
|
|
30
32
|
"""
|
|
33
|
+
self.__filepath = filepath
|
|
31
34
|
obj = None
|
|
32
35
|
try:
|
|
33
36
|
if filepath:
|
|
@@ -47,7 +50,7 @@ class YamlConfigurationProvider(ConfigurationProvider):
|
|
|
47
50
|
finally:
|
|
48
51
|
self.__obj = {} if obj is None else obj
|
|
49
52
|
|
|
50
|
-
def __populate_recursive(self, configuration: Configuration, prefix: str, o: dict) -> None:
|
|
53
|
+
def __populate_recursive(self, configuration: Configuration, prefix: str, o: dict[str, Any]) -> None:
|
|
51
54
|
for kvp in o.items():
|
|
52
55
|
if isinstance(kvp[1], dict):
|
|
53
56
|
self.__populate_recursive(
|
|
@@ -64,5 +67,10 @@ class YamlConfigurationProvider(ConfigurationProvider):
|
|
|
64
67
|
else:
|
|
65
68
|
configuration.set(f'{kvp[0]}', kvp[1])
|
|
66
69
|
|
|
70
|
+
@property
|
|
71
|
+
def filepath(self) -> str | None:
|
|
72
|
+
"""The file path used to initialize this provider, or ``None``."""
|
|
73
|
+
return self.__filepath
|
|
74
|
+
|
|
67
75
|
|
|
68
76
|
__all__ = ['YamlConfigurationProvider']
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: appsettings2
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Unifies configuration sources into a Configuration object that can be bound to complex types, or accessed directly for configuration data.
|
|
5
5
|
Author-email: Shaun Wilson <mrshaunwilson@msn.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -14,14 +14,14 @@ Classifier: Programming Language :: Python
|
|
|
14
14
|
Requires-Python: >=3.11
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
|
-
Requires-Dist: pyyaml>=6.0.
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
18
18
|
Provides-Extra: dev
|
|
19
19
|
Requires-Dist: coverage; extra == "dev"
|
|
20
20
|
Requires-Dist: flake8; extra == "dev"
|
|
21
21
|
Requires-Dist: flake8-pyproject; extra == "dev"
|
|
22
22
|
Requires-Dist: mypy; extra == "dev"
|
|
23
23
|
Requires-Dist: pep8-naming; extra == "dev"
|
|
24
|
-
Requires-Dist: punit>=1.
|
|
24
|
+
Requires-Dist: punit>=1.12.5; extra == "dev"
|
|
25
25
|
Requires-Dist: twine; extra == "dev"
|
|
26
26
|
Requires-Dist: types-PyYAML; extra == "dev"
|
|
27
27
|
Dynamic: license-file
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
src/appsettings2/Configuration.py
|
|
5
5
|
src/appsettings2/ConfigurationBuilder.py
|
|
6
6
|
src/appsettings2/ConfigurationException.py
|
|
7
|
+
src/appsettings2/ConfigurationWatcher.py
|
|
7
8
|
src/appsettings2/SKILL.md
|
|
8
9
|
src/appsettings2/__init__.py
|
|
9
10
|
src/appsettings2/helpers.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|