pybasemkit 0.0.1__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.
- basemkit/__init__.py +1 -0
- basemkit/base_cmd.py +215 -0
- basemkit/basetest.py +97 -0
- basemkit/persistent_log.py +127 -0
- basemkit/profiler.py +44 -0
- basemkit/shell.py +255 -0
- basemkit/yamlable.py +338 -0
- pybasemkit-0.0.1.dist-info/METADATA +48 -0
- pybasemkit-0.0.1.dist-info/RECORD +11 -0
- pybasemkit-0.0.1.dist-info/WHEEL +4 -0
- pybasemkit-0.0.1.dist-info/licenses/LICENSE +201 -0
basemkit/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
basemkit/base_cmd.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Created on 2025-06-16
|
|
3
|
+
|
|
4
|
+
Minimal reusable command line base class with standard options.
|
|
5
|
+
|
|
6
|
+
@author: wf
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import traceback
|
|
11
|
+
import webbrowser
|
|
12
|
+
from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseCmd:
|
|
16
|
+
"""
|
|
17
|
+
Minimal reusable command line base class with standard options:
|
|
18
|
+
--about, --debug, --force, --quiet, --verbose, --version.
|
|
19
|
+
|
|
20
|
+
Intended to be subclassed by tools requiring consistent CLI behavior.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, version, description: str = None):
|
|
24
|
+
"""
|
|
25
|
+
Initialize the BaseCmd instance.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
version: An object with .name, .version, .description, and .doc_url attributes.
|
|
29
|
+
description (str): Optional CLI description. Defaults to version.description.
|
|
30
|
+
"""
|
|
31
|
+
self.version = version
|
|
32
|
+
self.description = description or self.version.description
|
|
33
|
+
self.program_version_message = f"{self.version.name} {self.version.version}"
|
|
34
|
+
self.debug = False
|
|
35
|
+
self.quiet = False
|
|
36
|
+
self.verbose = False
|
|
37
|
+
self.force = False
|
|
38
|
+
self.parser = None
|
|
39
|
+
self.args = None
|
|
40
|
+
|
|
41
|
+
def add_arguments(self, parser: ArgumentParser):
|
|
42
|
+
"""
|
|
43
|
+
Add standard CLI arguments to the parser, sorted by long option name.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
parser (ArgumentParser): The parser to add arguments to.
|
|
47
|
+
"""
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"-a", "--about",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="show version info and open documentation"
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"-d", "--debug",
|
|
55
|
+
action="store_true",
|
|
56
|
+
help="enable debug output"
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--debugLocalPath",
|
|
60
|
+
help="remote debug Server path mapping - localPath - path on machine where python runs"
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--debugPort",
|
|
64
|
+
type=int,
|
|
65
|
+
default=5678,
|
|
66
|
+
help="remote debug Port [default: %(default)s]"
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--debugRemotePath",
|
|
70
|
+
help="remote debug Server path mapping - remotePath - path on debug server"
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--debugServer",
|
|
74
|
+
help="remote debug Server"
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"-f", "--force",
|
|
78
|
+
action="store_true",
|
|
79
|
+
help="force overwrite or unsafe actions"
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
"-q", "--quiet",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help="suppress all output"
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"-v", "--verbose",
|
|
88
|
+
action="store_true",
|
|
89
|
+
help="increase output verbosity"
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"-V", "--version",
|
|
93
|
+
action="version",
|
|
94
|
+
version=self.program_version_message
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_arg_parser(self) -> ArgumentParser:
|
|
99
|
+
"""
|
|
100
|
+
Create and configure the argument parser.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
ArgumentParser: The configured argument parser.
|
|
104
|
+
"""
|
|
105
|
+
parser = ArgumentParser(description=self.description, formatter_class=RawDescriptionHelpFormatter)
|
|
106
|
+
self.add_arguments(parser)
|
|
107
|
+
return parser
|
|
108
|
+
|
|
109
|
+
def parse_args(self, argv=None) -> Namespace:
|
|
110
|
+
"""
|
|
111
|
+
Parse command line arguments.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
argv (list): Optional list of command line arguments. Defaults to sys.argv.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Namespace: Parsed argument values.
|
|
118
|
+
"""
|
|
119
|
+
if self.parser is None:
|
|
120
|
+
self.parser = self.get_arg_parser()
|
|
121
|
+
self.args = self.parser.parse_args(argv)
|
|
122
|
+
return self.args
|
|
123
|
+
|
|
124
|
+
def optional_debug(self, args: Namespace):
|
|
125
|
+
"""
|
|
126
|
+
Optionally start remote debugging if debugServer is specified.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
args (Namespace): Parsed CLI arguments
|
|
130
|
+
"""
|
|
131
|
+
if args.debugServer:
|
|
132
|
+
import pydevd
|
|
133
|
+
import pydevd_file_utils
|
|
134
|
+
|
|
135
|
+
remote_path = args.debugRemotePath
|
|
136
|
+
local_path = args.debugLocalPath
|
|
137
|
+
|
|
138
|
+
if remote_path and local_path:
|
|
139
|
+
pydevd_file_utils.setup_client_server_paths([(remote_path, local_path)])
|
|
140
|
+
|
|
141
|
+
pydevd.settrace(
|
|
142
|
+
args.debugServer,
|
|
143
|
+
port=args.debugPort,
|
|
144
|
+
stdoutToServer=True,
|
|
145
|
+
stderrToServer=True,
|
|
146
|
+
)
|
|
147
|
+
print("Remote debugger attached.")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def handle_args(self, args: Namespace) -> bool:
|
|
151
|
+
"""
|
|
152
|
+
Handle parsed arguments. Intended to be overridden in subclasses.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
args (Namespace): Parsed argument namespace.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
bool: True if argument was handled and no further processing is required.
|
|
159
|
+
"""
|
|
160
|
+
self.args = args
|
|
161
|
+
self.debug = args.debug
|
|
162
|
+
self.quiet = args.quiet
|
|
163
|
+
self.verbose = args.verbose
|
|
164
|
+
self.force = args.force
|
|
165
|
+
self.optional_debug(args)
|
|
166
|
+
if args.about:
|
|
167
|
+
print(self.program_version_message)
|
|
168
|
+
print(f"see {self.version.doc_url}")
|
|
169
|
+
webbrowser.open(self.version.doc_url)
|
|
170
|
+
return True
|
|
171
|
+
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def run(self, argv=None) -> int:
|
|
175
|
+
"""
|
|
176
|
+
Execute the command line logic.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
argv (list): Optional command line arguments.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
int: Exit code: 0 = OK, 1 = KeyboardInterrupt, 2 = Exception.
|
|
183
|
+
"""
|
|
184
|
+
try:
|
|
185
|
+
args = self.parse_args(argv)
|
|
186
|
+
handled = self.handle_args(args)
|
|
187
|
+
exit_code = 0
|
|
188
|
+
if not handled:
|
|
189
|
+
exit_code = 0
|
|
190
|
+
except KeyboardInterrupt:
|
|
191
|
+
exit_code = 1
|
|
192
|
+
except Exception as e:
|
|
193
|
+
if self.debug:
|
|
194
|
+
raise
|
|
195
|
+
sys.stderr.write(f"{self.version.name}: {e}\n")
|
|
196
|
+
if getattr(self, "args", None) and self.args.debug:
|
|
197
|
+
sys.stderr.write(traceback.format_exc())
|
|
198
|
+
exit_code = 2
|
|
199
|
+
return exit_code
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def main(cls, version, argv=None) -> int:
|
|
203
|
+
"""
|
|
204
|
+
Entry point for scripts using this command line interface.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
version: Version metadata object passed to constructor.
|
|
208
|
+
argv (list): Optional command line arguments.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
int: Exit code from `run()`.
|
|
212
|
+
"""
|
|
213
|
+
instance = cls(version)
|
|
214
|
+
exit_code = instance.run(argv)
|
|
215
|
+
return exit_code
|
basemkit/basetest.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Created on 2021-08-19
|
|
3
|
+
|
|
4
|
+
@author: wf
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import getpass
|
|
8
|
+
import os
|
|
9
|
+
import threading
|
|
10
|
+
import unittest
|
|
11
|
+
from functools import wraps
|
|
12
|
+
from typing import Callable
|
|
13
|
+
|
|
14
|
+
from basemkit.profiler import Profiler
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Basetest(unittest.TestCase):
|
|
18
|
+
"""
|
|
19
|
+
base test case
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def setUp(self, debug=False, profile=True):
|
|
23
|
+
"""
|
|
24
|
+
setUp test environment
|
|
25
|
+
"""
|
|
26
|
+
unittest.TestCase.setUp(self)
|
|
27
|
+
self.debug = debug
|
|
28
|
+
self.profile = profile
|
|
29
|
+
msg = f"test {self._testMethodName}, debug={self.debug}"
|
|
30
|
+
self.profiler = Profiler(msg, profile=self.profile)
|
|
31
|
+
|
|
32
|
+
def tearDown(self):
|
|
33
|
+
unittest.TestCase.tearDown(self)
|
|
34
|
+
self.profiler.time()
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def inPublicCI():
|
|
38
|
+
"""
|
|
39
|
+
are we running in a public Continuous Integration Environment?
|
|
40
|
+
"""
|
|
41
|
+
publicCI = getpass.getuser() in ["travis", "runner"]
|
|
42
|
+
jenkins = "JENKINS_HOME" in os.environ
|
|
43
|
+
return publicCI or jenkins
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def isUser(name: str):
|
|
47
|
+
"""Checks if the system has the given name"""
|
|
48
|
+
return getpass.getuser() == name
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def timeout(seconds: float) -> Callable:
|
|
52
|
+
"""
|
|
53
|
+
Decorator to enforce a timeout on test methods.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
seconds (float): Timeout duration in seconds.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Callable: A decorator that wraps a function and raises TimeoutError
|
|
60
|
+
if it exceeds the allowed execution time.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
TimeoutError: If the wrapped function exceeds the timeout.
|
|
64
|
+
Exception: If the wrapped function raises any other exception.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def decorator(func):
|
|
68
|
+
@wraps(func)
|
|
69
|
+
def wrapper(*args, **kwargs):
|
|
70
|
+
result = [None]
|
|
71
|
+
exception = [None]
|
|
72
|
+
|
|
73
|
+
def target():
|
|
74
|
+
try:
|
|
75
|
+
result[0] = func(*args, **kwargs)
|
|
76
|
+
except Exception as e:
|
|
77
|
+
exception[0] = e
|
|
78
|
+
|
|
79
|
+
thread = threading.Thread(target=target)
|
|
80
|
+
thread.start()
|
|
81
|
+
thread.join(seconds)
|
|
82
|
+
|
|
83
|
+
if thread.is_alive():
|
|
84
|
+
raise TimeoutError(f"Test timed out after {seconds} seconds")
|
|
85
|
+
|
|
86
|
+
if exception[0] is not None:
|
|
87
|
+
raise exception[0]
|
|
88
|
+
|
|
89
|
+
return result[0]
|
|
90
|
+
|
|
91
|
+
return wrapper
|
|
92
|
+
|
|
93
|
+
return decorator
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
unittest.main()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Created on 2024-10-04
|
|
3
|
+
|
|
4
|
+
@author: wf
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from basemkit.yamlable import lod_storable
|
|
14
|
+
|
|
15
|
+
# ANSI colors
|
|
16
|
+
BLUE = "\033[0;34m"
|
|
17
|
+
RED = "\033[0;31m"
|
|
18
|
+
GREEN = "\033[0;32m"
|
|
19
|
+
END_COLOR = "\033[0m"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lod_storable
|
|
23
|
+
class LogEntry:
|
|
24
|
+
"""
|
|
25
|
+
Represents a log entry with a message, kind, and log level name.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
msg: str
|
|
29
|
+
kind: str
|
|
30
|
+
level_name: str
|
|
31
|
+
timestamp: Optional[str] = None
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
if self.timestamp is None:
|
|
35
|
+
self.timestamp = datetime.now().isoformat()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@lod_storable
|
|
39
|
+
class Log:
|
|
40
|
+
"""
|
|
41
|
+
Wrapper for persistent logging.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
entries: List[LogEntry] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def color_msg(self, color, msg):
|
|
47
|
+
"""Display a colored message"""
|
|
48
|
+
print(f"{color}{msg}{END_COLOR}")
|
|
49
|
+
|
|
50
|
+
def __post_init__(self):
|
|
51
|
+
"""
|
|
52
|
+
Initializes the log with level mappings and updates the level counts.
|
|
53
|
+
"""
|
|
54
|
+
self.do_log = True
|
|
55
|
+
self.do_print = False
|
|
56
|
+
self.levels = {"❌": logging.ERROR, "⚠️": logging.WARNING, "✅": logging.INFO}
|
|
57
|
+
self.level_names = {
|
|
58
|
+
logging.ERROR: "error",
|
|
59
|
+
logging.WARNING: "warn",
|
|
60
|
+
logging.INFO: "info",
|
|
61
|
+
}
|
|
62
|
+
self.update_level_counts()
|
|
63
|
+
|
|
64
|
+
def clear(self):
|
|
65
|
+
"""
|
|
66
|
+
Clears all log entries.
|
|
67
|
+
"""
|
|
68
|
+
self.entries = []
|
|
69
|
+
self.update_level_counts()
|
|
70
|
+
|
|
71
|
+
def update_level_counts(self):
|
|
72
|
+
"""
|
|
73
|
+
Updates the counts for each log level based on the existing entries.
|
|
74
|
+
"""
|
|
75
|
+
self.level_counts = {"error": Counter(), "warn": Counter(), "info": Counter()}
|
|
76
|
+
for entry in self.entries:
|
|
77
|
+
counter = self.get_counter(entry.level_name)
|
|
78
|
+
if counter is not None:
|
|
79
|
+
counter[entry.kind] += 1
|
|
80
|
+
|
|
81
|
+
def get_counter(self, level: str) -> Counter:
|
|
82
|
+
"""
|
|
83
|
+
Returns the counter for the specified log level.
|
|
84
|
+
"""
|
|
85
|
+
return self.level_counts.get(level)
|
|
86
|
+
|
|
87
|
+
def get_level_summary(self, level: str, limit: int = 7) -> Tuple[int, str]:
|
|
88
|
+
"""
|
|
89
|
+
Get a summary of the most common counts for the specified log level.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
level (str): The log level name ('error', 'warn', 'info').
|
|
93
|
+
limit (int): The maximum number of most common entries to include in the summary (default is 7).
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Tuple[int, str]: A tuple containing the count of log entries and a summary message.
|
|
97
|
+
"""
|
|
98
|
+
counter = self.get_counter(level)
|
|
99
|
+
if counter:
|
|
100
|
+
count = sum(counter.values())
|
|
101
|
+
most_common_entries = dict(counter.most_common(limit)) # Get the top 'limit' entries
|
|
102
|
+
summary_msg = f"{level.capitalize()} entries: {most_common_entries}"
|
|
103
|
+
return count, summary_msg
|
|
104
|
+
return 0, f"No entries found for level: {level}"
|
|
105
|
+
|
|
106
|
+
def log(self, icon: str, kind: str, msg: str):
|
|
107
|
+
"""
|
|
108
|
+
Log a message with the specified icon and kind.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
icon (str): The icon representing the log level ('❌', '⚠️', '✅').
|
|
112
|
+
kind (str): The category or type of the log message.
|
|
113
|
+
msg (str): The log message to record.
|
|
114
|
+
"""
|
|
115
|
+
level = self.levels.get(icon, logging.INFO)
|
|
116
|
+
level_name = self.level_names[level]
|
|
117
|
+
icon_msg = f"{icon}:{msg}"
|
|
118
|
+
log_entry = LogEntry(msg=icon_msg, level_name=level_name, kind=kind)
|
|
119
|
+
self.entries.append(log_entry)
|
|
120
|
+
|
|
121
|
+
# Update level counts
|
|
122
|
+
self.level_counts[level_name][kind] += 1
|
|
123
|
+
|
|
124
|
+
if self.do_log:
|
|
125
|
+
logging.log(level, icon_msg)
|
|
126
|
+
if self.do_print:
|
|
127
|
+
print(icon_msg)
|
basemkit/profiler.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Created on 2022-11-18
|
|
3
|
+
|
|
4
|
+
@author: wf
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Profiler:
|
|
11
|
+
"""
|
|
12
|
+
simple profiler
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, msg: str, profile=True, with_start: bool = True):
|
|
16
|
+
"""
|
|
17
|
+
Construct the profiler with the given message and flags.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
msg (str): The message to show if profiling is active.
|
|
21
|
+
profile (bool): True if profiling messages should be shown.
|
|
22
|
+
with_start (bool): If True, show start message immediately.
|
|
23
|
+
"""
|
|
24
|
+
self.msg = msg
|
|
25
|
+
self.profile = profile
|
|
26
|
+
if with_start:
|
|
27
|
+
self.start()
|
|
28
|
+
|
|
29
|
+
def start(self):
|
|
30
|
+
"""
|
|
31
|
+
start profiling
|
|
32
|
+
"""
|
|
33
|
+
self.starttime = time.time()
|
|
34
|
+
if self.profile:
|
|
35
|
+
print(f"Starting {self.msg} ...")
|
|
36
|
+
|
|
37
|
+
def time(self, extraMsg: str = ""):
|
|
38
|
+
"""
|
|
39
|
+
time the action and print if profile is active
|
|
40
|
+
"""
|
|
41
|
+
elapsed = time.time() - self.starttime
|
|
42
|
+
if self.profile:
|
|
43
|
+
print(f"{self.msg}{extraMsg} took {elapsed:5.1f} s")
|
|
44
|
+
return elapsed
|
basemkit/shell.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Created on 2025-05-14
|
|
3
|
+
|
|
4
|
+
@author: wf
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Dict, List
|
|
14
|
+
|
|
15
|
+
class ShellResult:
|
|
16
|
+
"""
|
|
17
|
+
result of a command line call
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, proc, success: bool):
|
|
21
|
+
self.proc = proc
|
|
22
|
+
self.success = success
|
|
23
|
+
|
|
24
|
+
def __str__(self):
|
|
25
|
+
text = self.as_text()
|
|
26
|
+
return text
|
|
27
|
+
|
|
28
|
+
def as_text(self, debug: bool = False):
|
|
29
|
+
if debug:
|
|
30
|
+
text = f"{self.proc.args} → rc={self.proc.returncode}, success={self.success}"
|
|
31
|
+
else:
|
|
32
|
+
text = "✅" if self.success else f"❌ → rc={self.proc.returncode}"
|
|
33
|
+
return text
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class StreamTee:
|
|
37
|
+
"""
|
|
38
|
+
Tees a single input stream to both a mirror and a capture buffer.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, source, mirror, buffer, tee=True):
|
|
42
|
+
self.source = source
|
|
43
|
+
self.mirror = mirror
|
|
44
|
+
self.buffer = buffer
|
|
45
|
+
self.tee = tee
|
|
46
|
+
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
47
|
+
|
|
48
|
+
def _run(self):
|
|
49
|
+
for line in iter(self.source.readline, ""):
|
|
50
|
+
if self.tee:
|
|
51
|
+
self.mirror.write(line)
|
|
52
|
+
self.mirror.flush()
|
|
53
|
+
self.buffer.write(line)
|
|
54
|
+
self.source.close()
|
|
55
|
+
|
|
56
|
+
def start(self):
|
|
57
|
+
self.thread.start()
|
|
58
|
+
|
|
59
|
+
def join(self):
|
|
60
|
+
self.thread.join()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SysTee:
|
|
64
|
+
"""
|
|
65
|
+
Tee sys.stdout and sys.stderr to a logfile while preserving original output.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, log_path: str):
|
|
69
|
+
self.logfile = open(log_path, "a")
|
|
70
|
+
self.original_stdout = sys.stdout
|
|
71
|
+
self.original_stderr = sys.stderr
|
|
72
|
+
sys.stdout = self
|
|
73
|
+
sys.stderr = self
|
|
74
|
+
|
|
75
|
+
def write(self, data):
|
|
76
|
+
self.original_stdout.write(data)
|
|
77
|
+
self.logfile.write(data)
|
|
78
|
+
|
|
79
|
+
def flush(self):
|
|
80
|
+
self.original_stdout.flush()
|
|
81
|
+
self.logfile.flush()
|
|
82
|
+
|
|
83
|
+
def close(self):
|
|
84
|
+
sys.stdout = self.original_stdout
|
|
85
|
+
sys.stderr = self.original_stderr
|
|
86
|
+
self.logfile.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class StdTee:
|
|
90
|
+
"""
|
|
91
|
+
Manages teeing for both stdout and stderr using StreamTee instances.
|
|
92
|
+
Captures output in instance variables.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, process, tee=True):
|
|
96
|
+
self.stdout_buffer = io.StringIO()
|
|
97
|
+
self.stderr_buffer = io.StringIO()
|
|
98
|
+
self.out_tee = StreamTee(process.stdout, sys.stdout, self.stdout_buffer, tee)
|
|
99
|
+
self.err_tee = StreamTee(process.stderr, sys.stderr, self.stderr_buffer, tee)
|
|
100
|
+
|
|
101
|
+
def start(self):
|
|
102
|
+
self.out_tee.start()
|
|
103
|
+
self.err_tee.start()
|
|
104
|
+
|
|
105
|
+
def join(self):
|
|
106
|
+
self.out_tee.join()
|
|
107
|
+
self.err_tee.join()
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def run(cls, process, tee=True):
|
|
111
|
+
"""
|
|
112
|
+
Run teeing and capture for the given process.
|
|
113
|
+
Returns a StdTee instance with stdout/stderr captured.
|
|
114
|
+
"""
|
|
115
|
+
std_tee = cls(process, tee=tee)
|
|
116
|
+
std_tee.start()
|
|
117
|
+
std_tee.join()
|
|
118
|
+
return std_tee
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Shell:
|
|
122
|
+
"""
|
|
123
|
+
Runs commands with environment from profile
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, profile=None, shell_path: str = None):
|
|
127
|
+
"""
|
|
128
|
+
Initialize shell with optional profile
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
profile: Path to profile file to source e.g. ~/.zprofile
|
|
132
|
+
shell_path: the shell_path e.g. /bin/zsh
|
|
133
|
+
"""
|
|
134
|
+
self.profile = profile
|
|
135
|
+
self.shell_path = shell_path
|
|
136
|
+
if self.shell_path is None:
|
|
137
|
+
self.shell_path = os.environ.get("SHELL", "/bin/bash")
|
|
138
|
+
self.shell_name = os.path.basename(self.shell_path)
|
|
139
|
+
if self.profile is None:
|
|
140
|
+
self.profile = self.find_profile()
|
|
141
|
+
|
|
142
|
+
def find_profile(self) -> str:
|
|
143
|
+
"""
|
|
144
|
+
Find the appropriate profile file for the current shell
|
|
145
|
+
|
|
146
|
+
Searches for the profile file corresponding to the shell_name
|
|
147
|
+
in the user's home directory.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
str: Path to the profile file or None if not found
|
|
151
|
+
"""
|
|
152
|
+
profile = None
|
|
153
|
+
home = os.path.expanduser("~")
|
|
154
|
+
# Try common profile files
|
|
155
|
+
profiles = {"zsh": ".zprofile", "bash": ".bash_profile", "sh": ".profile"}
|
|
156
|
+
if self.shell_name in profiles:
|
|
157
|
+
profile_name = profiles[self.shell_name]
|
|
158
|
+
path = os.path.join(home, profile_name)
|
|
159
|
+
if os.path.exists(path):
|
|
160
|
+
profile = path
|
|
161
|
+
return profile
|
|
162
|
+
|
|
163
|
+
@classmethod
|
|
164
|
+
def ofArgs(cls, args):
|
|
165
|
+
"""
|
|
166
|
+
Create Shell from command line args
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
args: Arguments with optional profile
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Shell: Configured Shell
|
|
173
|
+
"""
|
|
174
|
+
# Use explicit profile or detect
|
|
175
|
+
profile = getattr(args, "profile", None)
|
|
176
|
+
shell = cls(profile=profile)
|
|
177
|
+
return shell
|
|
178
|
+
|
|
179
|
+
def run(self, cmd, text=True, debug=False, tee=False) -> subprocess.CompletedProcess:
|
|
180
|
+
"""
|
|
181
|
+
Run command with profile, always capturing output and optionally teeing it.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
cmd: Command to run
|
|
185
|
+
text: Text mode for subprocess I/O
|
|
186
|
+
debug: Print the command to be run
|
|
187
|
+
tee: If True, also print output live while capturing
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
subprocess.CompletedProcess
|
|
191
|
+
"""
|
|
192
|
+
shell_cmd = f"source {self.profile} && {cmd}" if self.profile else cmd
|
|
193
|
+
|
|
194
|
+
if debug:
|
|
195
|
+
print(f"Running: {shell_cmd}")
|
|
196
|
+
|
|
197
|
+
popen_process = subprocess.Popen(
|
|
198
|
+
[self.shell_path, "-c", shell_cmd],
|
|
199
|
+
stdout=subprocess.PIPE,
|
|
200
|
+
stderr=subprocess.PIPE,
|
|
201
|
+
text=text,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
std_tee = StdTee.run(popen_process, tee=tee)
|
|
205
|
+
returncode = popen_process.wait()
|
|
206
|
+
|
|
207
|
+
process = subprocess.CompletedProcess(
|
|
208
|
+
args=popen_process.args,
|
|
209
|
+
returncode=returncode,
|
|
210
|
+
stdout=std_tee.stdout_buffer.getvalue(),
|
|
211
|
+
stderr=std_tee.stderr_buffer.getvalue(),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if process.returncode != 0:
|
|
215
|
+
if debug:
|
|
216
|
+
msg = f"""{process.args} failed:
|
|
217
|
+
returncode: {process.returncode}
|
|
218
|
+
stdout : {process.stdout.strip()}
|
|
219
|
+
stderr : {process.stderr.strip()}
|
|
220
|
+
"""
|
|
221
|
+
print(msg, file=sys.stderr)
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
return process
|
|
225
|
+
|
|
226
|
+
def proc_stats(
|
|
227
|
+
self,
|
|
228
|
+
title: str,
|
|
229
|
+
procs: Dict[Path, subprocess.CompletedProcess],
|
|
230
|
+
ignores: List[str] = [],
|
|
231
|
+
):
|
|
232
|
+
"""
|
|
233
|
+
Show process statistics with checkmark/crossmark and success/failure summary.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
title (str): A short title to label the output section.
|
|
237
|
+
procs (Dict[Path, subprocess.CompletedProcess]): Mapping of input files to their process results.
|
|
238
|
+
ignores (List[str], optional): List of substrings. If any is found in stderr, the error is ignored.
|
|
239
|
+
"""
|
|
240
|
+
total = len(procs)
|
|
241
|
+
failures = 0
|
|
242
|
+
print(f"\n{total} {title}:")
|
|
243
|
+
for idx, (path, result) in enumerate(procs.items(), start=1):
|
|
244
|
+
stderr = result.stderr or ""
|
|
245
|
+
stdout = result.stdout or ""
|
|
246
|
+
ignored = any(ignore in stderr for ignore in ignores)
|
|
247
|
+
has_error = (stderr and not ignored) or ("Error" in stdout)
|
|
248
|
+
if has_error:
|
|
249
|
+
symbol = "❌"
|
|
250
|
+
failures += 1
|
|
251
|
+
else:
|
|
252
|
+
symbol = "✅"
|
|
253
|
+
print(f"{symbol} {idx}/{total}: {path.name}")
|
|
254
|
+
percent_ok = ((total - failures) / total) * 100 if total > 0 else 0
|
|
255
|
+
print(f"\n✅ {total - failures}/{total} ({percent_ok:.1f}%), ❌ {failures}/{total} ({100 - percent_ok:.1f}%)")
|
basemkit/yamlable.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Created on 2023-12-08, Extended on 2023-16-12 and 2024-01-25
|
|
3
|
+
|
|
4
|
+
@author: wf, ChatGPT
|
|
5
|
+
|
|
6
|
+
Prompts for the development and extension of the 'YamlAble' class within the 'yamable' module:
|
|
7
|
+
|
|
8
|
+
1. Develop 'YamlAble' class in 'yamable' module. It
|
|
9
|
+
should convert dataclass instances to/from YAML.
|
|
10
|
+
2. Implement methods for YAML block scalar style and
|
|
11
|
+
exclude None values in 'YamlAble' class.
|
|
12
|
+
3. Add functionality to remove None values from
|
|
13
|
+
dataclass instances before YAML conversion.
|
|
14
|
+
4. Ensure 'YamlAble' processes only dataclass instances,
|
|
15
|
+
with error handling for non-dataclass objects.
|
|
16
|
+
5. Extend 'YamlAble' for JSON serialization and
|
|
17
|
+
deserialization.
|
|
18
|
+
6. Add methods for saving/loading dataclass instances
|
|
19
|
+
to/from YAML and JSON files in 'YamlAble'.
|
|
20
|
+
7. Implement loading of dataclass instances from URLs
|
|
21
|
+
for both YAML and JSON in 'YamlAble'.
|
|
22
|
+
8. Write tests for 'YamlAble' within the pyLodStorage context.
|
|
23
|
+
Use 'samples 2' example from pyLoDStorage
|
|
24
|
+
https://github.com/WolfgangFahl/pyLoDStorage/blob/master/lodstorage/sample2.py
|
|
25
|
+
as a reference.
|
|
26
|
+
9. Ensure tests cover YAML/JSON serialization, deserialization,
|
|
27
|
+
and file I/O operations, using the sample-based approach..
|
|
28
|
+
10. Use Google-style docstrings, comments, and type hints
|
|
29
|
+
in 'YamlAble' class and tests.
|
|
30
|
+
11. Adhere to instructions and seek clarification for
|
|
31
|
+
any uncertainties.
|
|
32
|
+
12. Add @lod_storable annotation support that will automatically
|
|
33
|
+
YamlAble support and add @dataclass and @dataclass_json
|
|
34
|
+
prerequisite behavior to a class
|
|
35
|
+
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import urllib.request
|
|
39
|
+
from collections.abc import Iterable, Mapping
|
|
40
|
+
from dataclasses import asdict, dataclass, is_dataclass
|
|
41
|
+
from datetime import date, datetime
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import Any, Generic, TextIO, Type, TypeVar, Union
|
|
44
|
+
|
|
45
|
+
import yaml
|
|
46
|
+
from dacite import from_dict
|
|
47
|
+
from dataclasses_json import dataclass_json
|
|
48
|
+
|
|
49
|
+
T = TypeVar("T")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def lod_storable(cls):
|
|
53
|
+
"""
|
|
54
|
+
Decorator to make a class LoDStorable by
|
|
55
|
+
inheriting from YamlAble.
|
|
56
|
+
This decorator also ensures the class is a
|
|
57
|
+
dataclass and has JSON serialization/deserialization
|
|
58
|
+
capabilities.
|
|
59
|
+
"""
|
|
60
|
+
cls = dataclass(cls) # Apply the @dataclass decorator
|
|
61
|
+
cls = dataclass_json(cls) # Apply the @dataclass_json decorator
|
|
62
|
+
|
|
63
|
+
class LoDStorable(YamlAble, cls):
|
|
64
|
+
"""
|
|
65
|
+
decorator class
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
__qualname__ = cls.__qualname__
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
LoDStorable.__name__ = cls.__name__
|
|
72
|
+
LoDStorable.__doc__ = cls.__doc__
|
|
73
|
+
|
|
74
|
+
return LoDStorable
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DateConvert:
|
|
78
|
+
"""
|
|
79
|
+
date converter
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def iso_date_to_datetime(cls, iso_date: str) -> date:
|
|
84
|
+
date = datetime.strptime(iso_date, "%Y-%m-%d").date() if iso_date else None
|
|
85
|
+
return date
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class YamlAble(Generic[T]):
|
|
89
|
+
"""
|
|
90
|
+
An extended YAML handler class for converting dataclass objects to and from YAML format,
|
|
91
|
+
and handling loading from and saving to files and URLs.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def _yaml_setup(self):
|
|
95
|
+
"""
|
|
96
|
+
Initializes the YamAble handler, setting up custom representers and preparing it for various operations.
|
|
97
|
+
"""
|
|
98
|
+
if not is_dataclass(self):
|
|
99
|
+
raise ValueError("I must be a dataclass instance.")
|
|
100
|
+
if not hasattr(self, "_yaml_dumper"):
|
|
101
|
+
self._yaml_dumper = yaml.Dumper
|
|
102
|
+
self._yaml_dumper.ignore_aliases = lambda *_args: True
|
|
103
|
+
self._yaml_dumper.add_representer(type(None), self.represent_none)
|
|
104
|
+
self._yaml_dumper.add_representer(str, self.represent_literal)
|
|
105
|
+
|
|
106
|
+
def represent_none(self, _, __) -> yaml.Node:
|
|
107
|
+
"""
|
|
108
|
+
Custom representer for ignoring None values in the YAML output.
|
|
109
|
+
"""
|
|
110
|
+
return self._yaml_dumper.represent_scalar("tag:yaml.org,2002:null", "")
|
|
111
|
+
|
|
112
|
+
def represent_literal(self, dumper: yaml.Dumper, data: str) -> yaml.Node:
|
|
113
|
+
"""
|
|
114
|
+
Custom representer for block scalar style for strings.
|
|
115
|
+
"""
|
|
116
|
+
if "\n" in data:
|
|
117
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
|
|
118
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", data)
|
|
119
|
+
|
|
120
|
+
def to_yaml(
|
|
121
|
+
self,
|
|
122
|
+
ignore_none: bool = True,
|
|
123
|
+
ignore_underscore: bool = True,
|
|
124
|
+
allow_unicode: bool = True,
|
|
125
|
+
sort_keys: bool = False,
|
|
126
|
+
) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Converts this dataclass object to a YAML string, with options to omit None values and/or underscore-prefixed variables,
|
|
129
|
+
and using block scalar style for strings.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
ignore_none: Flag to indicate whether None values should be removed from the YAML output.
|
|
133
|
+
ignore_underscore: Flag to indicate whether attributes starting with an underscore should be excluded from the YAML output.
|
|
134
|
+
allow_unicode: Flag to indicate whether to allow unicode characters in the output.
|
|
135
|
+
sort_keys: Flag to indicate whether to sort the dictionary keys in the output.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
A string representation of the dataclass object in YAML format.
|
|
139
|
+
"""
|
|
140
|
+
obj_dict = asdict(self)
|
|
141
|
+
self._yaml_setup()
|
|
142
|
+
clean_dict = self.remove_ignored_values(obj_dict, ignore_none, ignore_underscore)
|
|
143
|
+
yaml_str = yaml.dump(
|
|
144
|
+
clean_dict,
|
|
145
|
+
Dumper=self._yaml_dumper,
|
|
146
|
+
default_flow_style=False,
|
|
147
|
+
allow_unicode=allow_unicode,
|
|
148
|
+
sort_keys=sort_keys,
|
|
149
|
+
)
|
|
150
|
+
return yaml_str
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def from_yaml(cls: Type[T], yaml_str: str) -> T:
|
|
154
|
+
"""
|
|
155
|
+
Deserializes a YAML string to a dataclass instance.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
yaml_str (str): A string containing YAML formatted data.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
T: An instance of the dataclass.
|
|
162
|
+
"""
|
|
163
|
+
data: dict[str, Any] = yaml.safe_load(yaml_str)
|
|
164
|
+
instance: T = cls.from_dict(data)
|
|
165
|
+
return instance
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def load_from_yaml_stream(cls: Type[T], stream: TextIO) -> T:
|
|
169
|
+
"""
|
|
170
|
+
Loads a dataclass instance from a YAML stream.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
stream (TextIO): The input stream containing YAML data.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
T: An instance of the dataclass.
|
|
177
|
+
"""
|
|
178
|
+
yaml_str: str = stream.read()
|
|
179
|
+
instance: T = cls.from_yaml(yaml_str)
|
|
180
|
+
return instance
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def load_from_yaml_file(cls: Type[T], filename: str) -> T:
|
|
184
|
+
"""
|
|
185
|
+
Loads a dataclass instance from a YAML file.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
filename (str): The path to the YAML file.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
T: An instance of the dataclass.
|
|
192
|
+
"""
|
|
193
|
+
with open(filename, "r") as file:
|
|
194
|
+
return cls.load_from_yaml_stream(file)
|
|
195
|
+
|
|
196
|
+
@classmethod
|
|
197
|
+
def load_from_yaml_url(cls: Type[T], url: str) -> T:
|
|
198
|
+
"""
|
|
199
|
+
Loads a dataclass instance from a YAML string obtained from a URL.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
url (str): The URL pointing to the YAML data.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
T: An instance of the dataclass.
|
|
206
|
+
"""
|
|
207
|
+
yaml_str: str = cls.read_from_url(url)
|
|
208
|
+
instance: T = cls.from_yaml(yaml_str)
|
|
209
|
+
return instance
|
|
210
|
+
|
|
211
|
+
def save_to_yaml_stream(self, file: TextIO):
|
|
212
|
+
"""
|
|
213
|
+
Saves the current dataclass instance to the given YAML stream.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
file (TextIO): The stream to which YAML content will be saved.
|
|
217
|
+
"""
|
|
218
|
+
yaml_content: str = self.to_yaml()
|
|
219
|
+
file.write(yaml_content)
|
|
220
|
+
|
|
221
|
+
def save_to_yaml_file(self, filename: str):
|
|
222
|
+
"""
|
|
223
|
+
Saves the current dataclass instance to a YAML file.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
filename (str): The path where the YAML file will be saved.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
with open(filename, "w", encoding="utf-8") as file:
|
|
230
|
+
self.save_to_yaml_stream(file)
|
|
231
|
+
|
|
232
|
+
@classmethod
|
|
233
|
+
def load_from_json_file(cls: Type[T], filename: Union[str, Path]) -> T:
|
|
234
|
+
"""
|
|
235
|
+
Loads a dataclass instance from a JSON file.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
filename (str): The path to the JSON file.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
T: An instance of the dataclass.
|
|
242
|
+
"""
|
|
243
|
+
with open(filename, "r", encoding="utf-8") as file:
|
|
244
|
+
json_str: str = file.read()
|
|
245
|
+
instance: T = cls.from_json(json_str)
|
|
246
|
+
return instance
|
|
247
|
+
|
|
248
|
+
@classmethod
|
|
249
|
+
def load_from_json_url(cls: Type[T], url: str) -> T:
|
|
250
|
+
"""
|
|
251
|
+
Loads a dataclass instance from a JSON string obtained from a URL.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
url (str): The URL pointing to the JSON data.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
T: An instance of the dataclass.
|
|
258
|
+
"""
|
|
259
|
+
json_str: str = cls.read_from_url(url)
|
|
260
|
+
instance: T = cls.from_json(json_str)
|
|
261
|
+
return instance
|
|
262
|
+
|
|
263
|
+
def save_to_json_file(self, filename: str, **kwargs: Any):
|
|
264
|
+
"""
|
|
265
|
+
Saves the current dataclass instance to a JSON file.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
filename (str): The path where the JSON file will be saved.
|
|
269
|
+
**kwargs: Additional keyword arguments for the `to_json` method.
|
|
270
|
+
"""
|
|
271
|
+
json_content: str = self.to_json(**kwargs)
|
|
272
|
+
with open(filename, "w", encoding="utf-8") as file:
|
|
273
|
+
file.write(json_content)
|
|
274
|
+
|
|
275
|
+
@classmethod
|
|
276
|
+
def read_from_url(cls, url: str) -> str:
|
|
277
|
+
"""
|
|
278
|
+
Helper method to fetch content from a URL.
|
|
279
|
+
"""
|
|
280
|
+
with urllib.request.urlopen(url) as response:
|
|
281
|
+
if response.status == 200:
|
|
282
|
+
return response.read().decode()
|
|
283
|
+
else:
|
|
284
|
+
raise Exception(f"Unable to load data from URL: {url}")
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def remove_ignored_values(
|
|
288
|
+
cls,
|
|
289
|
+
value: Any,
|
|
290
|
+
ignore_none: bool = True,
|
|
291
|
+
ignore_underscore: bool = False,
|
|
292
|
+
ignore_empty: bool = True,
|
|
293
|
+
) -> Any:
|
|
294
|
+
"""
|
|
295
|
+
Recursively removes specified types of values from a dictionary or list.
|
|
296
|
+
By default, it removes keys with None values. Optionally, it can also remove keys starting with an underscore.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
value: The value to process (dictionary, list, or other).
|
|
300
|
+
ignore_none: Flag to indicate whether None values should be removed.
|
|
301
|
+
ignore_underscore: Flag to indicate whether keys starting with an underscore should be removed.
|
|
302
|
+
ignore_empty: Flag to indicate whether empty collections should be removed.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def is_valid(v):
|
|
306
|
+
"""Check if the value is valid based on the specified flags."""
|
|
307
|
+
if ignore_none and v is None:
|
|
308
|
+
return False
|
|
309
|
+
if ignore_empty:
|
|
310
|
+
if isinstance(v, Mapping) and not v:
|
|
311
|
+
return False # Empty dictionary
|
|
312
|
+
if isinstance(v, Iterable) and not isinstance(v, (str, bytes)) and not v:
|
|
313
|
+
return False # Empty list, set, tuple, etc., but not string or bytes
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
if isinstance(value, Mapping):
|
|
317
|
+
value = {
|
|
318
|
+
k: YamlAble.remove_ignored_values(v, ignore_none, ignore_underscore, ignore_empty)
|
|
319
|
+
for k, v in value.items()
|
|
320
|
+
if is_valid(v) and (not ignore_underscore or not k.startswith("_"))
|
|
321
|
+
}
|
|
322
|
+
elif isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
|
|
323
|
+
value = [
|
|
324
|
+
YamlAble.remove_ignored_values(v, ignore_none, ignore_underscore, ignore_empty)
|
|
325
|
+
for v in value
|
|
326
|
+
if is_valid(v)
|
|
327
|
+
]
|
|
328
|
+
return value
|
|
329
|
+
|
|
330
|
+
@classmethod
|
|
331
|
+
def from_dict2(cls: Type[T], data: dict) -> T:
|
|
332
|
+
"""
|
|
333
|
+
Creates an instance of a dataclass from a dictionary, typically used in deserialization.
|
|
334
|
+
"""
|
|
335
|
+
if not data:
|
|
336
|
+
return None
|
|
337
|
+
instance = from_dict(data_class=cls, data=data)
|
|
338
|
+
return instance
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pybasemkit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python base module kit: YAML/JSON I/O, structured logging, CLI tooling, shell execution, and pydevd remote debug support.
|
|
5
|
+
Project-URL: Home, https://github.com/WolfgangFahl/pybasemkit
|
|
6
|
+
Project-URL: Documentation, https://wiki.bitplan.com/index.php/pybasemkit
|
|
7
|
+
Project-URL: Source, https://github.com/WolfgangFahl/pybasemkit
|
|
8
|
+
Author-email: Wolfgang Fahl <wf@WolfgangFahl.com>
|
|
9
|
+
Maintainer-email: Wolfgang Fahl <wf@WolfgangFahl.com>
|
|
10
|
+
License: Apache-2.0
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: cli,dataclass,debug,infrastructure,logging,shell,yaml
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: dacite>=1.9.2
|
|
25
|
+
Requires-Dist: dataclasses-json>=0.6.7
|
|
26
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: black>=25.1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: isort>=6.0.1; extra == 'dev'
|
|
30
|
+
Provides-Extra: test
|
|
31
|
+
Requires-Dist: green>=3.3.0; extra == 'test'
|
|
32
|
+
Requires-Dist: pytest>=8.4.0; extra == 'test'
|
|
33
|
+
Requires-Dist: tox>=4.15.0; extra == 'test'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# pybasemkit
|
|
37
|
+
Python base module kit: YAML/JSON I/O, structured logging, CLI tooling, shell execution, and remote pydevd debug support.
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/pybasemkit/)
|
|
40
|
+
[](https://github.com/WolfgangFahl/pybasemkit/actions/workflows/build.yml)
|
|
41
|
+
[](https://pypi.python.org/pypi/pybasemkit/)
|
|
42
|
+
[](https://github.com/WolfgangFahl/pybasemkit/issues)
|
|
43
|
+
[](https://github.com/WolfgangFahl/pybasemkit/issues/?q=is%3Aissue+is%3Aclosed)
|
|
44
|
+
[](https://WolfgangFahl.github.io/pybasemkit/)
|
|
45
|
+
[](https://www.apache.org/licenses/LICENSE-2.0)
|
|
46
|
+
|
|
47
|
+
## Docs and Tutorials
|
|
48
|
+
[Wiki](https://wiki.bitplan.com/index.php/pybasemkit)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
basemkit/__init__.py,sha256=sXLh7g3KC4QCFxcZGBTpG2scR7hmmBsMjq6LqRptkRg,22
|
|
2
|
+
basemkit/base_cmd.py,sha256=-m_eEicjg_4B7QLl5yAp3eQEN_mqTv8TmKlOHGMFDKA,6452
|
|
3
|
+
basemkit/basetest.py,sha256=oirMWoUNBIdyf69j1gtYDgvqNyjmQ59e21yciABviOw,2483
|
|
4
|
+
basemkit/persistent_log.py,sha256=8L8VV6iaffC9QqiCYfgUdFQOIsKjYDglsCYX6vdODxM,3749
|
|
5
|
+
basemkit/profiler.py,sha256=h7N3jo43r6VS92UIARZMobNJYnsmpdk1zR4yVsyvhls,1059
|
|
6
|
+
basemkit/shell.py,sha256=BF6IQDNjVY3OuK6pSg_g8LyqfOcA-U_qol25J_ABgxc,7479
|
|
7
|
+
basemkit/yamlable.py,sha256=MSTK2VtRAutogKGiwPxpjXbw3akcwOoOqmf-LCncqEE,11613
|
|
8
|
+
pybasemkit-0.0.1.dist-info/METADATA,sha256=p3wurArE08SBBHtJXKXzJ3qIsT3ndZeHEt_kG5y7bbE,2697
|
|
9
|
+
pybasemkit-0.0.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
10
|
+
pybasemkit-0.0.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
11
|
+
pybasemkit-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|