sc-foundation-services 3.0.2__py3-none-any.whl
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.
- sc_foundation/__init__.py +14 -0
- sc_foundation/sc_common.py +349 -0
- sc_foundation/sc_config_mgr.py +280 -0
- sc_foundation/sc_csv_reader.py +500 -0
- sc_foundation/sc_date_helper.py +877 -0
- sc_foundation/sc_json_encoder.py +286 -0
- sc_foundation/sc_logging.py +510 -0
- sc_foundation/validation_schema.py +28 -0
- sc_foundation_services-3.0.2.dist-info/METADATA +47 -0
- sc_foundation_services-3.0.2.dist-info/RECORD +12 -0
- sc_foundation_services-3.0.2.dist-info/WHEEL +5 -0
- sc_foundation_services-3.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sc-foundation package.
|
|
3
|
+
|
|
4
|
+
This package provides functions and classes for the Spello Consulting Foundation package.
|
|
5
|
+
"""
|
|
6
|
+
from .sc_common import SCCommon
|
|
7
|
+
from .sc_config_mgr import SCConfigManager
|
|
8
|
+
from .sc_csv_reader import CSVReader
|
|
9
|
+
from .sc_date_helper import DateHelper
|
|
10
|
+
from .sc_json_encoder import JSONEncoder
|
|
11
|
+
from .sc_logging import SCLogger
|
|
12
|
+
from .validation_schema import yaml_config_validation
|
|
13
|
+
|
|
14
|
+
__all__ = ["CSVReader", "DateHelper", "JSONEncoder", "SCCommon", "SCConfigManager", "SCLogger", "yaml_config_validation"]
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Common functions and classes used by other classes in the sc_foundation package."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import re
|
|
7
|
+
import subprocess # noqa: S404
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import validators
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SCCommon:
|
|
15
|
+
"""Common functions and classes used by other classes in the sc_foundation package."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def is_valid_hostname(target: str) -> bool:
|
|
19
|
+
"""Return whether target is a valid IPv4, IPv6, or DNS hostname.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
target: The target string to validate.
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
A boolean indicating validity.
|
|
26
|
+
"""
|
|
27
|
+
result, _ = SCCommon.check_hostname_and_type(target)
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def check_hostname_and_type(target: str) -> tuple[bool, str | None]:
|
|
32
|
+
"""Return whether target is a valid IPv4, IPv6, or DNS hostname. Also returns the type.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
target: The target string to validate.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A tuple containing a boolean indicating validity and a string indicating the type ('ipv4', 'ipv6', or 'hostname').
|
|
39
|
+
"""
|
|
40
|
+
# Make sure the target is a string
|
|
41
|
+
if not isinstance(target, str):
|
|
42
|
+
return False, None
|
|
43
|
+
|
|
44
|
+
# Check strict IPv4
|
|
45
|
+
try:
|
|
46
|
+
ipaddress.IPv4Address(target)
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
else:
|
|
50
|
+
if target.count(".") == 3:
|
|
51
|
+
return True, "ipv4"
|
|
52
|
+
|
|
53
|
+
# Check strict IPv6
|
|
54
|
+
try:
|
|
55
|
+
ipaddress.IPv6Address(target)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
else:
|
|
59
|
+
# If it is a valid IPv6 address, return True
|
|
60
|
+
return True, "ipv6"
|
|
61
|
+
|
|
62
|
+
# Reject if it looks like a malformed IP (like 192.168.1 or 256.1.1.1)
|
|
63
|
+
if re.fullmatch(r"[0-9.]+", target):
|
|
64
|
+
return False, None
|
|
65
|
+
|
|
66
|
+
# Validate hostname using validators library
|
|
67
|
+
if validators.domain(target) or validators.hostname(target, rfc_1034=True):
|
|
68
|
+
return True, "hostname"
|
|
69
|
+
|
|
70
|
+
return False, None
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def ping_host(ip_address: str, timeout: int = 1) -> bool:
|
|
74
|
+
"""Pings an IP address and returns True if the host is responding, False otherwise.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
ip_address: The IP address to ping.
|
|
78
|
+
timeout: Timeout in seconds for the ping response. Default is 1 second.
|
|
79
|
+
|
|
80
|
+
Raises:
|
|
81
|
+
RuntimeError: If the IP address is invalid or the ping system call fails.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
result (bool): True if the host responds, False otherwise.
|
|
85
|
+
"""
|
|
86
|
+
# Determine the ping command based on the operating system
|
|
87
|
+
param = "-n" if platform.system().lower() == "windows" else "-c"
|
|
88
|
+
|
|
89
|
+
if not SCCommon.is_valid_hostname(ip_address):
|
|
90
|
+
error_msg = f"Invalid IP address: {ip_address}"
|
|
91
|
+
raise RuntimeError(error_msg)
|
|
92
|
+
|
|
93
|
+
command = ["ping", param, "1", "-W", str(timeout), ip_address]
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
# Run the ping command using subprocess for better security
|
|
97
|
+
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=False, check=False) # noqa: S603
|
|
98
|
+
response_code = result.returncode
|
|
99
|
+
except OSError as e:
|
|
100
|
+
error_msg = f"Error pinging {ip_address}: {e}"
|
|
101
|
+
raise RuntimeError(error_msg) from e
|
|
102
|
+
else:
|
|
103
|
+
# Return True if the ping was successful (exit code 0)
|
|
104
|
+
return response_code == 0
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def check_internet_connection(urls=None, timeout: int = 3) -> bool:
|
|
108
|
+
"""Check if the system has an active internet connection by trying to open a connection to common websites.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
urls (list): A list of URLs to check for internet connectivity. Defaults to common DNS servers and websites.
|
|
112
|
+
timeout (int): The timeout in seconds for each request.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if the system is connected to the internet, False otherwise.
|
|
116
|
+
"""
|
|
117
|
+
if urls is None:
|
|
118
|
+
urls = [
|
|
119
|
+
"https://1.1.1.1", # Cloudflare DNS
|
|
120
|
+
"https://8.8.8.8", # Google DNS
|
|
121
|
+
"https://www.google.com",
|
|
122
|
+
"https://www.cloudflare.com"
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
for url in urls:
|
|
126
|
+
try:
|
|
127
|
+
response = httpx.get(url, timeout=timeout, follow_redirects=True)
|
|
128
|
+
if response.status_code < 400:
|
|
129
|
+
return True
|
|
130
|
+
except httpx.RequestError:
|
|
131
|
+
continue
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def get_os() -> str:
|
|
136
|
+
"""Return the name of the operating system.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
The name of the operating system in lowercase.
|
|
140
|
+
"""
|
|
141
|
+
# Get the platform name and convert it to lowercase
|
|
142
|
+
platform_name = platform.system().lower()
|
|
143
|
+
|
|
144
|
+
if platform_name == "darwin":
|
|
145
|
+
platform_name = "macos"
|
|
146
|
+
|
|
147
|
+
return platform_name
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def is_probable_path(possible_path: str | Path) -> bool:
|
|
151
|
+
"""Check if the given string or Path object is likely to be a file path.
|
|
152
|
+
|
|
153
|
+
This method checks if the string is an absolute path, contains a path separator, or has a file extension.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
possible_path: The string to check.
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
True if the string is likely a file path, False otherwise.
|
|
160
|
+
"""
|
|
161
|
+
max_path = 260 if SCCommon.get_os() == "windows" else os.pathconf("/", "PC_PATH_MAX")
|
|
162
|
+
|
|
163
|
+
path_obj = None
|
|
164
|
+
if isinstance(possible_path, Path):
|
|
165
|
+
path_str = str(possible_path)
|
|
166
|
+
path_obj = possible_path
|
|
167
|
+
else:
|
|
168
|
+
path_str = possible_path
|
|
169
|
+
|
|
170
|
+
if len(path_str) > max_path:
|
|
171
|
+
# If the path is longer than the maximum allowed path length, it cannot be a valid path
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
if path_obj is None:
|
|
175
|
+
path_obj = Path(possible_path)
|
|
176
|
+
|
|
177
|
+
# Check if it's absolute, or contains a path separator, or has a file extension
|
|
178
|
+
if path_obj.is_absolute():
|
|
179
|
+
return True
|
|
180
|
+
|
|
181
|
+
if "/" in path_str or "\\" in path_str:
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
# Check if the path has a file extension
|
|
185
|
+
return bool(path_obj.suffix and path_obj.suffix.lower() is not None)
|
|
186
|
+
|
|
187
|
+
@staticmethod
|
|
188
|
+
def get_project_root(marker_files=("pyproject.toml", ".project_root", "uv.lock", ".git")) -> Path:
|
|
189
|
+
"""Return the root folder of the Python project.
|
|
190
|
+
|
|
191
|
+
By default, this function looks for marker files like pyproject.toml, .project_root, uv.lock, or .git to
|
|
192
|
+
identify the project root. It starts from the directory of this file and walks upwards until it finds one
|
|
193
|
+
of the marker files. If it cannot find any of the marker files, it raises a RuntimeError.
|
|
194
|
+
|
|
195
|
+
If the environment variable SC_FOUNDATION_PROJECT_ROOT is set, it will check if that path exists and is a directory,
|
|
196
|
+
and return it as the project root if so. This allows users to override the automatic detection of the project
|
|
197
|
+
root if needed (e.g., if they have an unusual project structure or want to use the foundation in a different project
|
|
198
|
+
without copying this file).
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
marker_files (tuple): A tuple of file names that indicate the project root.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
RuntimeError: If the project root cannot be found.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
root_dir (Path): The root folder of the Python project as a Path object.
|
|
208
|
+
"""
|
|
209
|
+
path = None
|
|
210
|
+
env_path = os.environ.get("SC_FOUNDATION_PROJECT_ROOT") # Issue 32
|
|
211
|
+
if env_path:
|
|
212
|
+
path = Path(env_path).resolve()
|
|
213
|
+
if path and path.exists() and path.is_dir():
|
|
214
|
+
return path
|
|
215
|
+
|
|
216
|
+
# Default behaviour is to look for the project root based on the location of this file and the presence of marker files. This allows the foundation to be used in other projects without requiring users
|
|
217
|
+
path = Path(__file__).resolve()
|
|
218
|
+
|
|
219
|
+
# Walk upwards until we find a marker file
|
|
220
|
+
for parent in [path, *list(path.parents)]:
|
|
221
|
+
for marker in marker_files:
|
|
222
|
+
if (parent / marker).exists():
|
|
223
|
+
return parent
|
|
224
|
+
|
|
225
|
+
error_msg = f"Project root not found. Looked for markers: {marker_files}"
|
|
226
|
+
if env_path:
|
|
227
|
+
error_msg += f" (also checked SC_FOUNDATION_PROJECT_ROOT={env_path})"
|
|
228
|
+
raise RuntimeError(error_msg)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def select_file_location(file_name: str, create_folder: bool = False) -> Path | None:
|
|
232
|
+
"""Select the file location for the given file name. It resolves an absolute path for the file_name as follows.
|
|
233
|
+
|
|
234
|
+
1. If file_name is an absolute path, return it as a Path object.
|
|
235
|
+
2. If file_name is a relative path (contains parent directories), return the absolute path based on the current working directory.
|
|
236
|
+
3. If file_name is just a file name, look for it in the current working directory first, then in the root directory.
|
|
237
|
+
|
|
238
|
+
The root directly is defined as the directory containing the main script being executed (the module containing __main__).
|
|
239
|
+
|
|
240
|
+
Raises:
|
|
241
|
+
RuntimeError: If the project root cannot be determined.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
file_name: The name of the file to locate. Can be just a file name, or a relative or absolute path.
|
|
245
|
+
create_folder: If True, create the parent folder if it does not exist. Default is False.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
file_path (Path): The full path to the file as a Path object. None if the file_name does not appear to be a path.
|
|
249
|
+
"""
|
|
250
|
+
return_file_path = None
|
|
251
|
+
|
|
252
|
+
# Look at the file_name and see if it looks like a path
|
|
253
|
+
if not SCCommon.is_probable_path(file_name):
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
# Check to see if file_name is a full path or just a file name
|
|
257
|
+
return_file_path = Path(file_name)
|
|
258
|
+
|
|
259
|
+
# Check if file_name is an absolute path, return this even if it does not exist
|
|
260
|
+
if return_file_path.is_absolute():
|
|
261
|
+
SCCommon._create_folder_if_not_exists(return_file_path.parent) if create_folder else None
|
|
262
|
+
return return_file_path
|
|
263
|
+
|
|
264
|
+
# Check if file_name contains any parent directories (i.e., is a relative path)
|
|
265
|
+
# If so, return this even if it does not exist
|
|
266
|
+
if return_file_path.parent != Path("."): # noqa: PTH201
|
|
267
|
+
# It's a relative path
|
|
268
|
+
return_file_path = (Path.cwd() / return_file_path).resolve()
|
|
269
|
+
SCCommon._create_folder_if_not_exists(return_file_path.parent) if create_folder else None
|
|
270
|
+
return return_file_path
|
|
271
|
+
|
|
272
|
+
# Otherwise, assume it's just a file name and look for it in the current directory and the script directory
|
|
273
|
+
current_dir = Path.cwd()
|
|
274
|
+
return_file_path = current_dir / file_name
|
|
275
|
+
if not return_file_path.exists():
|
|
276
|
+
try:
|
|
277
|
+
project_root_dir = SCCommon.get_project_root()
|
|
278
|
+
return_file_path = project_root_dir / file_name
|
|
279
|
+
except RuntimeError as e:
|
|
280
|
+
error_msg = f"Cannot determine project root to locate file '{file_name}': {e}"
|
|
281
|
+
raise RuntimeError(error_msg) from e
|
|
282
|
+
|
|
283
|
+
if return_file_path:
|
|
284
|
+
SCCommon._create_folder_if_not_exists(return_file_path.parent) if create_folder else None
|
|
285
|
+
|
|
286
|
+
return return_file_path
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def select_folder_location(folder_path: str | None = None, create_folder: bool = False) -> Path | None:
|
|
290
|
+
"""Return an absolute folder path for the given (relative) folder path.
|
|
291
|
+
|
|
292
|
+
If folder_path is None, return the project root folder.
|
|
293
|
+
If folder_path is an absolute path, return it as a Path object.
|
|
294
|
+
If folder_path is a relative path, return the absolute path based on the project root directory.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
folder_path: The folder path to locate. Can be None, or a relative or absolute path.
|
|
298
|
+
create_folder: If True, create the folder if it does not exist. Default is False.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
RuntimeError: If the project root cannot be determined or if folder creation fails.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
The full path to the folder as a Path object. None if folder_path is None and project root cannot be determined.
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
project_root = SCCommon.get_project_root()
|
|
308
|
+
except RuntimeError as e:
|
|
309
|
+
raise RuntimeError(e) from e
|
|
310
|
+
|
|
311
|
+
if folder_path is None:
|
|
312
|
+
return project_root
|
|
313
|
+
|
|
314
|
+
selected_folder = Path(folder_path)
|
|
315
|
+
|
|
316
|
+
# Check if folder_path is an absolute path, return this even if it does not exist
|
|
317
|
+
if not selected_folder.is_absolute():
|
|
318
|
+
selected_folder = (project_root / selected_folder).resolve()
|
|
319
|
+
|
|
320
|
+
if create_folder:
|
|
321
|
+
SCCommon._create_folder_if_not_exists(selected_folder)
|
|
322
|
+
|
|
323
|
+
return selected_folder
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
def get_process_id() -> int:
|
|
327
|
+
"""Return the process ID of the current process.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
The process ID of the current process.
|
|
331
|
+
"""
|
|
332
|
+
return os.getpid()
|
|
333
|
+
|
|
334
|
+
@staticmethod
|
|
335
|
+
def _create_folder_if_not_exists(folder_path: Path) -> None:
|
|
336
|
+
"""Create the folder if it does not exist.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
folder_path: The path of the folder to create.
|
|
340
|
+
|
|
341
|
+
Raises:
|
|
342
|
+
RuntimeError: If folder creation fails.
|
|
343
|
+
"""
|
|
344
|
+
if not folder_path.exists():
|
|
345
|
+
try:
|
|
346
|
+
folder_path.mkdir(parents=True, exist_ok=True)
|
|
347
|
+
except OSError as e:
|
|
348
|
+
error_msg = f"Error creating folder '{folder_path}': {e}"
|
|
349
|
+
raise RuntimeError(error_msg) from e
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Spello Consulting Configuration Manager Module.
|
|
2
|
+
|
|
3
|
+
Management of a YAML log file.
|
|
4
|
+
"""
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from cerberus import Validator
|
|
12
|
+
from mergedeep import merge
|
|
13
|
+
|
|
14
|
+
from sc_foundation.sc_common import SCCommon
|
|
15
|
+
from sc_foundation.sc_date_helper import DateHelper
|
|
16
|
+
from sc_foundation.validation_schema import yaml_config_validation
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SCConfigManager:
|
|
20
|
+
"""Loads the configuration from a YAML file, validates it, and provides access to the configuration values."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config_file: str, default_config: dict | None = None, validation_schema: dict | None = None, placeholders: dict | None = None):
|
|
23
|
+
"""Initializes the configuration manager.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config_file (str): The relative or absolute path to the configuration file.
|
|
27
|
+
default_config (Optional[dict], optional): A default configuration dict to use if the config file does not exist.
|
|
28
|
+
validation_schema (Optional[dict], optional): A cerberus style validation schema dict to validate the config file against.
|
|
29
|
+
placeholders (Optional[dict], optional): A dictionary of placeholders to check in the config. If any of these are found, a exception will be raised.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
RuntimeError: If the config file does not exist and no default config is provided, or if there are YAML errors in the config file.
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
self._config = {} # Intialise the actual config object
|
|
36
|
+
self.config_file = config_file
|
|
37
|
+
self.logger_function = None # Placeholder for a logger function
|
|
38
|
+
self.placeholders = placeholders
|
|
39
|
+
|
|
40
|
+
# Build the full validation schema
|
|
41
|
+
if validation_schema is None:
|
|
42
|
+
self.validation_schema = yaml_config_validation
|
|
43
|
+
else:
|
|
44
|
+
self.validation_schema = merge({}, yaml_config_validation, validation_schema)
|
|
45
|
+
|
|
46
|
+
# Determine the file path for the log file
|
|
47
|
+
self.config_path = SCCommon.select_file_location(self.config_file)
|
|
48
|
+
if self.config_path is None:
|
|
49
|
+
msg = f"Cannot find config file {self.config_file}. Please check the path."
|
|
50
|
+
raise RuntimeError(msg)
|
|
51
|
+
|
|
52
|
+
# If the config file doesn't exist and we have a default config, write that to file
|
|
53
|
+
if not self.config_path.exists():
|
|
54
|
+
if default_config is None:
|
|
55
|
+
msg = f"Cannot find config file {self.config_file} and no default config provided."
|
|
56
|
+
raise RuntimeError(msg)
|
|
57
|
+
|
|
58
|
+
with Path(self.config_path).open("w", encoding="utf-8") as file:
|
|
59
|
+
yaml.dump(default_config, file)
|
|
60
|
+
|
|
61
|
+
# Now load the config file
|
|
62
|
+
self.load_config()
|
|
63
|
+
|
|
64
|
+
def load_config(self) -> bool:
|
|
65
|
+
"""Load the configuration from the config file specified to the __init__ method.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
RuntimeError: If there are YAML errors in the config file, if placeholders are found, or if validation fails.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
result (bool): True if the configuration was loaded successfully, otherwise False.
|
|
72
|
+
"""
|
|
73
|
+
if not self.config_path:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
with Path(self.config_path).open(encoding="utf-8") as file:
|
|
77
|
+
try:
|
|
78
|
+
self._config = yaml.safe_load(file)
|
|
79
|
+
|
|
80
|
+
except yaml.YAMLError as e:
|
|
81
|
+
msg = f"YAML error in config file {self.config_file}: {e}"
|
|
82
|
+
raise RuntimeError(msg) from e
|
|
83
|
+
|
|
84
|
+
else:
|
|
85
|
+
# Make sure there are no placeholders in the config file, exit if there are
|
|
86
|
+
self.check_for_placeholders(self.placeholders)
|
|
87
|
+
|
|
88
|
+
# If we have a validation schema, validate the config
|
|
89
|
+
if self.validation_schema is not None:
|
|
90
|
+
v = Validator()
|
|
91
|
+
|
|
92
|
+
if not v.validate(self._config, self.validation_schema): # type: ignore[call-arg]
|
|
93
|
+
# Format cerberus errors into human readable lines like "path.to.field: error message"
|
|
94
|
+
|
|
95
|
+
error_lines = self._format_validator_errors(v.errors) # type: ignore[call-arg]
|
|
96
|
+
nice = "\n".join(error_lines)
|
|
97
|
+
msg = f"Validation error for config file {self.config_path}: \n{nice}"
|
|
98
|
+
raise RuntimeError(msg)
|
|
99
|
+
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _format_validator_errors(err, path=""):
|
|
104
|
+
msgs = []
|
|
105
|
+
# dict: descend into keys
|
|
106
|
+
if isinstance(err, dict):
|
|
107
|
+
for k, vv in err.items():
|
|
108
|
+
new_path = f"{path}: {k}" if path else str(k)
|
|
109
|
+
msgs.extend(SCConfigManager._format_validator_errors(vv, new_path))
|
|
110
|
+
return msgs
|
|
111
|
+
# list: may contain strings or nested dicts (e.g. list of item errors)
|
|
112
|
+
if isinstance(err, list):
|
|
113
|
+
for idx, item in enumerate(err):
|
|
114
|
+
if isinstance(item, (dict, list)):
|
|
115
|
+
# for list-items that are dicts, include index in path
|
|
116
|
+
if isinstance(item, dict):
|
|
117
|
+
new_path = f"{path}" if path else f"[{idx}]"
|
|
118
|
+
msgs.extend(SCConfigManager._format_validator_errors(item, new_path))
|
|
119
|
+
else:
|
|
120
|
+
msgs.extend(SCConfigManager._format_validator_errors(item, path))
|
|
121
|
+
# item is an error string
|
|
122
|
+
elif path:
|
|
123
|
+
msgs.append(f"{path} - {item}")
|
|
124
|
+
else:
|
|
125
|
+
msgs.append(str(item))
|
|
126
|
+
return msgs
|
|
127
|
+
# fallback
|
|
128
|
+
if path:
|
|
129
|
+
msgs.append(f"{path}: {err}")
|
|
130
|
+
else:
|
|
131
|
+
msgs.append(str(err))
|
|
132
|
+
return msgs
|
|
133
|
+
|
|
134
|
+
def get_config_file_last_modified(self) -> dt.datetime | None:
|
|
135
|
+
"""Get the last modified time of the config file.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
dt.datetime | None: The last modified time if the config file exists, None otherwise.
|
|
139
|
+
"""
|
|
140
|
+
if not self.config_path:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
return DateHelper.get_file_datetime(self.config_path)
|
|
144
|
+
|
|
145
|
+
def check_for_config_changes(self, last_check: dt.datetime) -> dt.datetime | None:
|
|
146
|
+
"""Check if the configuration file has changed. If it has, reload the configuration.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
last_check (dt.datetime): The last time the config was checked.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
result (dt.datetime | None): The new last modified time if the config has changed and was reloaded, None otherwise.
|
|
153
|
+
"""
|
|
154
|
+
last_modified_dt = self.get_config_file_last_modified()
|
|
155
|
+
if last_modified_dt is None:
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
if last_check is None or last_modified_dt > last_check:
|
|
159
|
+
# The config file has changed, reload it
|
|
160
|
+
self.load_config()
|
|
161
|
+
return last_modified_dt
|
|
162
|
+
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
def register_logger(self, logger_function: Callable) -> None:
|
|
166
|
+
"""Registers a logger function to be used for logging messages.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
logger_function (Callable): The function to use for logging messages.
|
|
170
|
+
"""
|
|
171
|
+
self.logger_function = logger_function
|
|
172
|
+
|
|
173
|
+
def check_for_placeholders(self, placeholders: dict | None) -> bool:
|
|
174
|
+
"""Recursively scan self._config for any instances of a key found in placeholders.
|
|
175
|
+
|
|
176
|
+
If the keys and values match (including nested), return True.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
placeholders (dict): A dictionary of placeholders to check in the config.
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
RuntimeError: If any placeholder is found in the config file, an exception will be raised with a message indicating the placeholder and its value.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
result (bool): True if any placeholders are found in the config, otherwise False.
|
|
186
|
+
""" # noqa: DOC502
|
|
187
|
+
def recursive_check(config_section, placeholder_section):
|
|
188
|
+
for key, placeholder_value in placeholder_section.items():
|
|
189
|
+
if key and key in config_section:
|
|
190
|
+
config_value = config_section[key]
|
|
191
|
+
if isinstance(placeholder_value, dict) and isinstance(config_value, dict):
|
|
192
|
+
if recursive_check(config_value, placeholder_value):
|
|
193
|
+
return True
|
|
194
|
+
elif config_value == placeholder_value:
|
|
195
|
+
msg = f"Placeholder value '{key}: {placeholder_value}' found in config file {self.config_path}. Please fix this."
|
|
196
|
+
raise RuntimeError(msg)
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
if placeholders is None:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
return recursive_check(self._config, placeholders)
|
|
203
|
+
|
|
204
|
+
def get(self, *keys, default=None):
|
|
205
|
+
"""Retrieve a value from the config dictionary using a sequence of nested keys.
|
|
206
|
+
|
|
207
|
+
Example:
|
|
208
|
+
value = config_mgr.get("DeviceType", "WebsiteAccessKey")
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
keys (*keys): Sequence of keys to traverse the config dictionary.
|
|
212
|
+
default (Optional[variable], optional): Value to return if the key path does not exist.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
value (variable): The value if found, otherwise the default.
|
|
216
|
+
|
|
217
|
+
"""
|
|
218
|
+
value = self._config
|
|
219
|
+
try:
|
|
220
|
+
for key in keys:
|
|
221
|
+
value = value[key]
|
|
222
|
+
except (KeyError, TypeError):
|
|
223
|
+
return default
|
|
224
|
+
else:
|
|
225
|
+
return value
|
|
226
|
+
|
|
227
|
+
def get_logger_settings(self, config_section: str | None = "Files") -> dict:
|
|
228
|
+
"""Returns the logger settings from the config file.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
config_section (Optional[str], optional): The section in the config file where logger settings are stored.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
settings (dict): A dictionary of logger settings that can be passed to the SCLogger() class initialization.
|
|
235
|
+
"""
|
|
236
|
+
logger_settings = {
|
|
237
|
+
"logfile_name": self.get(config_section, "LogfileName"),
|
|
238
|
+
"file_verbosity": self.get(config_section, "LogfileVerbosity", default="summary"),
|
|
239
|
+
"console_verbosity": self.get(config_section, "ConsoleVerbosity", default="summary"),
|
|
240
|
+
"max_lines": self.get(config_section, "LogfileMaxLines", default=10000),
|
|
241
|
+
"timestamp_format": self.get(config_section, "TimestampFormat", default="%Y-%m-%d %H:%M:%S"),
|
|
242
|
+
"log_process_id": self.get(config_section, "LogProcessID", default=False),
|
|
243
|
+
"log_thread_id": self.get(config_section, "LogThreadID", default=False),
|
|
244
|
+
}
|
|
245
|
+
return logger_settings
|
|
246
|
+
|
|
247
|
+
def get_email_settings(self, config_section: str | None = "Email") -> dict | None:
|
|
248
|
+
"""Returns the email settings from the config file.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
config_section (Optional[str], optional): The section in the config file where email settings are stored.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
settings (dict): A dictionary of email settings or None if email is disabled or not configured correctly.
|
|
255
|
+
"""
|
|
256
|
+
# fir check to see if we have an EnableEmail setting
|
|
257
|
+
enable_email = self.get(config_section, "EnableEmail", default=True)
|
|
258
|
+
if not enable_email:
|
|
259
|
+
return None
|
|
260
|
+
smtp_username = os.environ.get("SMTP_USERNAME")
|
|
261
|
+
if not smtp_username:
|
|
262
|
+
smtp_username = self.get(config_section, "SMTPUsername")
|
|
263
|
+
smtp_password = os.environ.get("SMTP_PASSWORD")
|
|
264
|
+
if not smtp_password:
|
|
265
|
+
smtp_password = self.get(config_section, "SMTPPassword")
|
|
266
|
+
|
|
267
|
+
email_settings = {
|
|
268
|
+
"SendEmailsTo": self.get(config_section, "SendEmailsTo"),
|
|
269
|
+
"SMTPServer": self.get(config_section, "SMTPServer"),
|
|
270
|
+
"SMTPUsername": smtp_username,
|
|
271
|
+
"SMTPPassword": smtp_password,
|
|
272
|
+
"SubjectPrefix": self.get(config_section, "SubjectPrefix"),
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Only return true if all the required email settings have been specified (excluding SubjectPrefix)
|
|
276
|
+
required_fields = {k: v for k, v in email_settings.items() if k != "SubjectPrefix"}
|
|
277
|
+
if all(required_fields.values()):
|
|
278
|
+
return email_settings
|
|
279
|
+
|
|
280
|
+
return None
|