Skip to content

Commit 0f5ccad

Browse files
author
Antoine
committed
Add TOMLConfig and MemoryConfig classes
1 parent eec63cb commit 0f5ccad

File tree

8 files changed

+434
-99
lines changed

8 files changed

+434
-99
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ a collection of python classes and functions that I use in my projects
55

66
### `config.py`
77

8-
A class to read and write configuration files, currently only in json format.
8+
A class to read and write configuration files, in json or toml format.
9+
Also support memory-only configuration (do not persist to file).
910
Support nested dictionaries and lists, as well as combined keys (e.g. `a.b.c`).
1011
Also support referencing other keys :
11-
```properties
12+
```python
1213
a = "hello"
1314
b = "${a} world"
1415
```

config/config/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
from .config import BaseConfig, JSONConfig
1+
from .config import MemoryConfig, JSONConfig, TOMLConfig

config/config/config.py

Lines changed: 156 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
Configuration management module.
55
"""
66

7-
from json import loads, dump, JSONDecodeError
7+
from json import loads, dumps
88
from abc import ABC, abstractmethod
99
from datetime import datetime
1010
import re
1111
import os
12+
import tomlkit
1213
from typing import Any, Dict
1314

1415
try: # use gamuLogger if available # pragma: no cover
@@ -17,40 +18,21 @@
1718
def _trace(msg: str) -> None:
1819
Logger.trace(msg)
1920
except ImportError: # pragma: no cover
20-
def _trace(_: str) -> None:
21-
pass
21+
def _trace(msg: str) -> None:
22+
print(msg)
2223

2324

2425

25-
class BaseConfig(ABC):
26+
class BaseConfig:
2627
"""
2728
Base class for configuration management.
28-
This class provides methods to load, save, and manage configuration settings.
29-
It is designed to be subclassed for specific configuration formats (e.g., JSON, YAML).
29+
This class provides methods to manage configuration settings.
30+
Can be subclassed for specific configuration formats (e.g., JSON, YAML).
3031
"""
3132
RE_REFERENCE = re.compile(r'\$\{([a-zA-Z0-9_.]+)\}')
3233

3334
def __init__(self):
3435
self._config : Dict[str, Any] = {}
35-
self._load()
36-
37-
@abstractmethod
38-
def _load(self) -> 'BaseConfig':
39-
"""
40-
Load configuration
41-
"""
42-
43-
@abstractmethod
44-
def _save(self) -> 'BaseConfig':
45-
"""
46-
Save configuration
47-
"""
48-
49-
@abstractmethod
50-
def _reload(self) -> 'BaseConfig':
51-
"""
52-
Reload configuration
53-
"""
5436

5537
def get(self, key: str, /, default: Any = None, set_if_not_found: bool = False) -> str | int | float | bool:
5638
"""
@@ -61,7 +43,6 @@ def get(self, key: str, /, default: Any = None, set_if_not_found: bool = False)
6143
:return: Configuration value.
6244
"""
6345
_trace(f"Getting config value for key: {key}")
64-
self._reload()
6546
key_tokens = key.split('.')
6647
config = self._config
6748
for token in key_tokens:
@@ -92,7 +73,6 @@ def set(self, key: str, value : Any) -> 'BaseConfig':
9273
:param value: Configuration value.
9374
"""
9475
_trace(f"Setting config value for key: {key} to {value}")
95-
self._reload()
9676
key_tokens = key.split('.')
9777
config = self._config
9878
for token in key_tokens[:-1]:
@@ -103,7 +83,6 @@ def set(self, key: str, value : Any) -> 'BaseConfig':
10383
config[key_tokens[-1]] = value
10484
except TypeError:
10585
raise TypeError(f"Cannot set value for key '{key}' because key is already a non-dict type.") from None
106-
self._save()
10786
return self
10887

10988
def remove(self, key: str) -> 'BaseConfig':
@@ -113,7 +92,6 @@ def remove(self, key: str) -> 'BaseConfig':
11392
:param key: Configuration key.
11493
"""
11594
_trace(f"Removing config key: {key}")
116-
self._reload()
11795
key_tokens = key.split('.')
11896
config = self._config
11997
for token in key_tokens[:-1]:
@@ -129,37 +107,52 @@ def remove(self, key: str) -> 'BaseConfig':
129107
raise KeyError(f"Key '{key}' not found in configuration.")
130108
return self
131109

110+
def __str__(self) -> str:
111+
"""
112+
String representation of the configuration.
113+
"""
114+
return str(self._config)
132115

133-
class JSONConfig(BaseConfig):
116+
def __repr__(self) -> str:
117+
"""
118+
String representation of the configuration.
119+
"""
120+
return f"{self.__class__.__name__}({self._config})"
121+
122+
class FileConfig(BaseConfig, ABC):
134123
"""
135-
JSON configuration management class.
136-
This class provides methods to load, save, and manage configuration settings in JSON format.
124+
File configuration management class.
125+
126+
Must be subclassed for specific file formats (e.g., JSON, TOML) that implement `_to_string` and `_from_string`.
137127
"""
138128
def __init__(self, file_path: str):
139129
self.file_path = file_path
140130
self._last_modified = datetime.now()
141131
super().__init__()
132+
self._load()
133+
134+
def __init_empty(self) -> 'FileConfig':
135+
self._config = {}
136+
self._save()
137+
return self
142138

143-
def _load(self) -> 'JSONConfig':
139+
def _load(self) -> 'FileConfig':
144140
"""
145-
Load configuration from a JSON file.
141+
Load configuration from a config file.
142+
the _from_string method must be implemented in subclasses.
146143
"""
147144
_trace(f"Loading configuration from {self.file_path}")
148145
if not os.path.exists(self.file_path):
149-
self._config = {}
150-
self._save()
151-
return self
146+
return self.__init_empty()
152147
with open(self.file_path, 'r', encoding="utf-8") as file:
153148
content = file.read()
154149
if content.strip() == "":
155150
_trace(f"Configuration file {self.file_path} is empty, creating empty config")
156-
self._config = {}
157-
self._save()
158-
return self
159-
self._config = loads(content)
151+
return self.__init_empty()
152+
self._from_string(content)
160153
return self
161154

162-
def _reload(self) -> 'JSONConfig':
155+
def _reload(self) -> 'FileConfig':
163156
"""
164157
Reload configuration from a JSON file if the modification time has changed.
165158
"""
@@ -169,20 +162,138 @@ def _reload(self) -> 'JSONConfig':
169162
return self
170163
file_modified_time = os.path.getmtime(self.file_path) #when the file was last modified
171164
config_modified_time = self._last_modified.timestamp() #when the config was last modified (this object)
172-
if self._last_modified is None or file_modified_time > config_modified_time:
165+
if file_modified_time > config_modified_time:
173166
_trace(f"Reloading configuration from {self.file_path} due to modification time change")
174167
self._load()
175168
self._last_modified = datetime.fromtimestamp(file_modified_time)
176169
else:
177170
_trace(f"Configuration file {self.file_path} has not changed since last load")
178171
return self
179172

180-
def _save(self) -> 'JSONConfig':
173+
def _save(self) -> 'FileConfig':
181174
"""
182175
Save configuration to a JSON file.
183176
"""
184177
_trace(f"Saving configuration to {self.file_path}")
185178
with open(self.file_path, 'w', encoding="utf-8") as file:
186-
dump(self._config, file, indent=4)
179+
file.write(self._to_string())
187180
self._last_modified = datetime.now()
188181
return self
182+
183+
def get(self, key: str, /, default: Any = None, set_if_not_found: bool = False) -> str | int | float | bool:
184+
"""
185+
Get the value of a configuration key.
186+
187+
:param key: Configuration key.
188+
:param default: Default value if the key does not exist.
189+
:return: Configuration value.
190+
"""
191+
self._reload()
192+
return super().get(key, default, set_if_not_found)
193+
194+
def set(self, key: str, value: Any) -> 'FileConfig':
195+
"""
196+
Set the value of a configuration key.
197+
198+
:param key: Configuration key.
199+
:param value: Configuration value.
200+
"""
201+
self._reload()
202+
super().set(key, value)
203+
self._save()
204+
return self
205+
206+
def remove(self, key: str) -> 'FileConfig':
207+
"""
208+
Remove a configuration key.
209+
210+
:param key: Configuration key.
211+
"""
212+
self._reload()
213+
super().remove(key)
214+
self._save()
215+
return self
216+
217+
@abstractmethod
218+
def _to_string(self) -> str:
219+
"""
220+
String representation of the configuration.
221+
"""
222+
223+
@abstractmethod
224+
def _from_string(self, config_string: str) -> None:
225+
"""
226+
Create a configuration object from a string.
227+
228+
:param config_string: Configuration string.
229+
:return: Configuration object.
230+
"""
231+
232+
class JSONConfig(FileConfig):
233+
"""
234+
JSON configuration management class.
235+
This class provides methods to load, save, and manage configuration settings in JSON format.
236+
"""
237+
238+
def _to_string(self) -> str:
239+
"""
240+
String representation of the configuration in JSON format.
241+
"""
242+
return dumps(self._config, indent=4)
243+
244+
def _from_string(self, config_string: str) -> None:
245+
"""
246+
Create a configuration object from a JSON string.
247+
248+
:param config_string: Configuration string.
249+
:return: Configuration object.
250+
"""
251+
self._config = loads(config_string)
252+
if not isinstance(self._config, dict):
253+
raise ValueError("Invalid JSON format: expected a dictionary.")
254+
255+
class TOMLConfig(FileConfig):
256+
"""
257+
TOML configuration management class.
258+
This class provides methods to load, save, and manage configuration settings in TOML format.
259+
"""
260+
261+
def _to_string(self) -> str:
262+
"""
263+
String representation of the configuration in TOML format.
264+
"""
265+
return tomlkit.dumps(self._config)
266+
267+
def _from_string(self, config_string: str) -> None:
268+
"""
269+
Create a configuration object from a TOML string.
270+
271+
:param config_string: Configuration string.
272+
:return: Configuration object.
273+
"""
274+
self._config = tomlkit.loads(config_string)
275+
276+
class MemoryConfig(BaseConfig):
277+
"""
278+
In-memory configuration management class.
279+
This class provides methods to load, save, and manage configuration settings in memory.
280+
Does not persist to a file.
281+
"""
282+
def __init__(self, initial: Dict[str, Any] = None):
283+
super().__init__()
284+
if initial is not None:
285+
self._config = initial
286+
287+
288+
def get_config(file_path: str) -> FileConfig:
289+
"""
290+
Get a configuration object based on the file extension.
291+
292+
:param file_path: Path to the configuration file.
293+
:return: Configuration object.
294+
"""
295+
if file_path.lower().endswith('.json'):
296+
return JSONConfig(file_path)
297+
if file_path.lower().endswith('.toml'):
298+
return TOMLConfig(file_path)
299+
raise ValueError(f"Unsupported configuration file format: {file_path}")

config/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ authors = [
1010
{ name = "Antoine BUIREY", email = "antoine.buirey@gmail.com" }
1111
]
1212
requires-python = ">=3.10"
13+
dependencies = [
14+
"tomlkit==0.13.2"
15+
]
1316

1417
[tool.setuptools.packages.find]
1518
include = [

config/tests/file_tests.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import os
2+
import pytest
3+
from unittest.mock import patch, mock_open
4+
import builtins
5+
import tempfile
6+
7+
8+
# raise a ImportError for "import gamuLogger"
9+
base_import = builtins.__import__
10+
def mock_import(name, globals=None, locals=None, fromlist=(), level=0):
11+
if name == "gamuLogger":
12+
raise ImportError("Mocked ImportError for gamuLogger")
13+
return base_import(name, globals, locals, fromlist, level)
14+
builtins.__import__ = mock_import
15+
16+
from config.config.config import FileConfig
17+
18+
class DummyFileConfig(FileConfig):
19+
"""
20+
Dummy implementation of FileConfig for testing purposes.
21+
"""
22+
def _to_string(self) -> str:
23+
return str(self._config)
24+
25+
def _from_string(self, config_string: str) -> None:
26+
self._config = eval(config_string)
27+
28+
29+
class TestFileConfig:
30+
@pytest.fixture
31+
def file_config(self):
32+
with tempfile.NamedTemporaryFile(delete=False, mode="w+") as temp_file:
33+
temp_file.write('{"key": "value"}')
34+
temp_file_path = temp_file.name
35+
config = DummyFileConfig(temp_file_path)
36+
yield config
37+
os.remove(temp_file_path)
38+
39+
def test_get(self, file_config : DummyFileConfig):
40+
assert file_config.get('key') == 'value'
41+
assert file_config.get('non_existent_key', default='default_value') == 'default_value'
42+
43+
def test_set(self, file_config : DummyFileConfig):
44+
file_config.set('new_key', 'new_value')
45+
assert file_config.get('new_key') == 'new_value'
46+
47+
def test_remove(self, file_config : DummyFileConfig):
48+
file_config.set('key_to_remove', 'value')
49+
file_config.remove('key_to_remove')
50+
assert file_config.get('key_to_remove', default='default_value') == 'default_value'
51+
52+
def test_str(self, file_config : DummyFileConfig):
53+
assert str(file_config) == "{'key': 'value'}"
54+
55+
def test_repr(self, file_config : DummyFileConfig):
56+
assert repr(file_config) == f"DummyFileConfig({file_config._config})"

0 commit comments

Comments
 (0)