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.
@@ -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