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.
Files changed (25) hide show
  1. {appsettings2-2.0.14/src/appsettings2.egg-info → appsettings2-2.1.0}/PKG-INFO +3 -3
  2. {appsettings2-2.0.14 → appsettings2-2.1.0}/pyproject.toml +5 -5
  3. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/Configuration.py +160 -37
  4. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/ConfigurationBuilder.py +48 -4
  5. appsettings2-2.1.0/src/appsettings2/ConfigurationWatcher.py +375 -0
  6. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/__init__.py +7 -3
  7. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/helpers.py +4 -2
  8. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/ConfigurationProvider.py +2 -1
  9. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/JsonConfigurationProvider.py +12 -4
  10. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/TomlConfigurationProvider.py +13 -5
  11. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/YamlConfigurationProvider.py +14 -6
  12. {appsettings2-2.0.14 → appsettings2-2.1.0/src/appsettings2.egg-info}/PKG-INFO +3 -3
  13. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2.egg-info/SOURCES.txt +1 -0
  14. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2.egg-info/requires.txt +2 -2
  15. {appsettings2-2.0.14 → appsettings2-2.1.0}/LICENSE +0 -0
  16. {appsettings2-2.0.14 → appsettings2-2.1.0}/README.md +0 -0
  17. {appsettings2-2.0.14 → appsettings2-2.1.0}/setup.cfg +0 -0
  18. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/ConfigurationException.py +0 -0
  19. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/SKILL.md +0 -0
  20. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/CommandLineConfigurationProvider.py +1 -1
  21. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/EnvironmentConfigurationProvider.py +0 -0
  22. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/providers/__init__.py +0 -0
  23. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2/py.typed +0 -0
  24. {appsettings2-2.0.14 → appsettings2-2.1.0}/src/appsettings2.egg-info/dependency_links.txt +0 -0
  25. {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.14
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.2
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.10.8; extra == "dev"
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.14"
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.2"
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.10.8)",
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-ignore = [
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
- def __init__(self, normalize: bool = False, scrubkeys: bool = False) -> None:
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
- while len(self.__keys) > 0:
405
- t = self.__keys.popitem()
406
- delattr(self, t[1])
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) -> 'Configuration':
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 build(self) -> Configuration:
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, scrubkeys=self.__scrubkeys)
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'
14
- __commit__ = '654e291'
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
@@ -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."""
@@ -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
- __obj: dict
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']
@@ -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
- __obj: dict
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']
@@ -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
- __obj: dict
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.14
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.2
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.10.8; extra == "dev"
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
@@ -1,4 +1,4 @@
1
- pyyaml>=6.0.2
1
+ pyyaml>=6.0.3
2
2
 
3
3
  [dev]
4
4
  coverage
@@ -6,6 +6,6 @@ flake8
6
6
  flake8-pyproject
7
7
  mypy
8
8
  pep8-naming
9
- punit>=1.10.8
9
+ punit>=1.12.5
10
10
  twine
11
11
  types-PyYAML
File without changes
File without changes
File without changes
@@ -4,8 +4,8 @@
4
4
  import sys
5
5
  from typing import Optional
6
6
 
7
- from ..Configuration import Configuration
8
7
  from .ConfigurationProvider import ConfigurationProvider
8
+ from ..Configuration import Configuration
9
9
 
10
10
 
11
11
  class CommandLineConfigurationProvider(ConfigurationProvider):