sc-foundation-services 3.0.2__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 (24) hide show
  1. sc_foundation_services-3.0.2/PKG-INFO +47 -0
  2. sc_foundation_services-3.0.2/README.md +14 -0
  3. sc_foundation_services-3.0.2/pyproject.toml +64 -0
  4. sc_foundation_services-3.0.2/setup.cfg +4 -0
  5. sc_foundation_services-3.0.2/src/sc_foundation/__init__.py +14 -0
  6. sc_foundation_services-3.0.2/src/sc_foundation/sc_common.py +349 -0
  7. sc_foundation_services-3.0.2/src/sc_foundation/sc_config_mgr.py +280 -0
  8. sc_foundation_services-3.0.2/src/sc_foundation/sc_csv_reader.py +500 -0
  9. sc_foundation_services-3.0.2/src/sc_foundation/sc_date_helper.py +877 -0
  10. sc_foundation_services-3.0.2/src/sc_foundation/sc_json_encoder.py +286 -0
  11. sc_foundation_services-3.0.2/src/sc_foundation/sc_logging.py +510 -0
  12. sc_foundation_services-3.0.2/src/sc_foundation/validation_schema.py +28 -0
  13. sc_foundation_services-3.0.2/src/sc_foundation_services.egg-info/PKG-INFO +47 -0
  14. sc_foundation_services-3.0.2/src/sc_foundation_services.egg-info/SOURCES.txt +22 -0
  15. sc_foundation_services-3.0.2/src/sc_foundation_services.egg-info/dependency_links.txt +1 -0
  16. sc_foundation_services-3.0.2/src/sc_foundation_services.egg-info/not-zip-safe +1 -0
  17. sc_foundation_services-3.0.2/src/sc_foundation_services.egg-info/requires.txt +27 -0
  18. sc_foundation_services-3.0.2/src/sc_foundation_services.egg-info/top_level.txt +1 -0
  19. sc_foundation_services-3.0.2/tests/test_sc_common.py +216 -0
  20. sc_foundation_services-3.0.2/tests/test_sc_config_mgr.py +81 -0
  21. sc_foundation_services-3.0.2/tests/test_sc_csv_reader.py +160 -0
  22. sc_foundation_services-3.0.2/tests/test_sc_date_helper.py +302 -0
  23. sc_foundation_services-3.0.2/tests/test_sc_json_encoder.py +43 -0
  24. sc_foundation_services-3.0.2/tests/test_sc_logging.py +86 -0
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.4
2
+ Name: sc-foundation-services
3
+ Version: 3.0.2
4
+ Summary: Spello Consulting foundation package. A Python library for log file management; config file management; CSV and JSON file operations and more.
5
+ Project-URL: Homepage, https://github.com/NickElseySpelloC
6
+ Project-URL: Repository, https://github.com/NickElseySpelloC/sc-foundation
7
+ Requires-Python: >=3.13
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: astral>=3.2
10
+ Requires-Dist: cerberus>=1.3.8
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: mergedeep>=1.3.4
13
+ Requires-Dist: python-dateutil>=2.9.0.post0
14
+ Requires-Dist: pytz>=2026.1.post1
15
+ Requires-Dist: pyyaml>=6.0.3
16
+ Requires-Dist: timezonefinder>=8.2.2
17
+ Requires-Dist: tzdata>=2026.1
18
+ Requires-Dist: validators>=0.35.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.3.5; extra == "dev"
21
+ Requires-Dist: pytest-mock>=3.15.1; extra == "dev"
22
+ Requires-Dist: pre-commit>=3.5.0; extra == "dev"
23
+ Requires-Dist: pytest-dotenv>=0.5.2; extra == "dev"
24
+ Provides-Extra: docs
25
+ Requires-Dist: mkdocs<2.0.0,>=1.6.1; extra == "docs"
26
+ Requires-Dist: mkdocs-include-markdown-plugin>=6.2.2; extra == "docs"
27
+ Requires-Dist: mkdocs-material>=9.6.14; extra == "docs"
28
+ Requires-Dist: mkdocstrings>=0.26.1; extra == "docs"
29
+ Requires-Dist: mkdocstrings-python>=1.11.1; extra == "docs"
30
+ Requires-Dist: pdoc>=14.7.0; extra == "docs"
31
+ Provides-Extra: all
32
+ Requires-Dist: sc-foundation[dev,docs]; extra == "all"
33
+
34
+ # Spello Consulting Foundation Library
35
+
36
+ A Python utility library for log file management and YAML configuration file management.
37
+
38
+ Please see the [GitHub pages](https://nickelseyspelloc.github.io/sc-foundation/) for complete documentation.
39
+
40
+ ## Development Environment
41
+
42
+ Note: If making changes to the sc-foundation library, use this command to sync in all the library dependencies including unit test and documentation tools:
43
+
44
+ ```bash
45
+ uv sync --extra all
46
+ source .venv/bin/activate
47
+ ```
@@ -0,0 +1,14 @@
1
+ # Spello Consulting Foundation Library
2
+
3
+ A Python utility library for log file management and YAML configuration file management.
4
+
5
+ Please see the [GitHub pages](https://nickelseyspelloc.github.io/sc-foundation/) for complete documentation.
6
+
7
+ ## Development Environment
8
+
9
+ Note: If making changes to the sc-foundation library, use this command to sync in all the library dependencies including unit test and documentation tools:
10
+
11
+ ```bash
12
+ uv sync --extra all
13
+ source .venv/bin/activate
14
+ ```
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "sc-foundation-services"
3
+ version = "3.0.2"
4
+ description = "Spello Consulting foundation package. A Python library for log file management; config file management; CSV and JSON file operations and more."
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "astral>=3.2",
9
+ "cerberus>=1.3.8",
10
+ "httpx>=0.28.1",
11
+ "mergedeep>=1.3.4",
12
+ "python-dateutil>=2.9.0.post0",
13
+ "pytz>=2026.1.post1",
14
+ "pyyaml>=6.0.3",
15
+ "timezonefinder>=8.2.2",
16
+ "tzdata>=2026.1",
17
+ "validators>=0.35.0",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["setuptools>=61.0", "wheel"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [project.optional-dependencies]
25
+ dev = [
26
+ "pytest>=8.3.5",
27
+ "pytest-mock>=3.15.1",
28
+ "pre-commit>=3.5.0",
29
+ "pytest-dotenv>=0.5.2",
30
+ ]
31
+ docs = [
32
+ "mkdocs>=1.6.1,<2.0.0",
33
+ "mkdocs-include-markdown-plugin>=6.2.2",
34
+ "mkdocs-material>=9.6.14",
35
+ "mkdocstrings>=0.26.1",
36
+ "mkdocstrings-python>=1.11.1",
37
+ "pdoc>=14.7.0",
38
+ ]
39
+ all = [
40
+ "sc-foundation[dev,docs]",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/NickElseySpelloC"
45
+ Repository = "https://github.com/NickElseySpelloC/sc-foundation"
46
+
47
+ [tool.setuptools]
48
+ package-dir = { "" = "src" }
49
+ include-package-data = true
50
+ zip-safe = false
51
+
52
+ [tool.setuptools.packages.find]
53
+ where = ["src"]
54
+
55
+ [dev.env.sync]
56
+ remote="~/Library/CloudStorage/Dropbox/Development/dev_setup/app_config/sc-foundation"
57
+ patterns = [
58
+ ".env",
59
+ ".vscode/launch.json",
60
+ "development/*.yaml",
61
+ "dev_testdevelopmenting/*.log",
62
+ "development/*.json",
63
+ "tests/config.yaml",
64
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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