XulbuX 1.4.26__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.
Potentially problematic release.
This version of XulbuX might be problematic. Click here for more details.
- XulbuX/XulbuX.py +1386 -0
- XulbuX/__init__.py +24 -0
- xulbux-1.4.26.dist-info/METADATA +19 -0
- xulbux-1.4.26.dist-info/RECORD +6 -0
- xulbux-1.4.26.dist-info/WHEEL +4 -0
- xulbux-1.4.26.dist-info/licenses/LICENSE +19 -0
XulbuX/XulbuX.py
ADDED
|
@@ -0,0 +1,1386 @@
|
|
|
1
|
+
"""
|
|
2
|
+
>>> import XulbuX as xx
|
|
3
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
4
|
+
* CUSTOM TYPES:
|
|
5
|
+
* rgb(int,int,int,float)
|
|
6
|
+
* hsl(int,int,int,float)
|
|
7
|
+
* hexa(str)
|
|
8
|
+
* DIRECTORY OPERATIONS xx.Dir
|
|
9
|
+
* FILE OPERATIONS xx.File
|
|
10
|
+
* JSON FILE OPERATIONS xx.Json
|
|
11
|
+
* SYSTEM ACTIONS xx.System
|
|
12
|
+
* MANAGE ENVIRONMENT VARS xx.Env_vars
|
|
13
|
+
* CMD LOG AND ACTIONS xx.Cmd
|
|
14
|
+
* PRETTY PRINTING xx.FormatCodes
|
|
15
|
+
* COLOR OPERATIONS xx.Color
|
|
16
|
+
* DATA OPERATIONS xx.Data
|
|
17
|
+
* STR OPERATIONS xx.String
|
|
18
|
+
* CODE STRING OPERATIONS xx.Code
|
|
19
|
+
* REGEX PATTERN TEMPLATES xx.Regex
|
|
20
|
+
"""
|
|
21
|
+
import regex as rx
|
|
22
|
+
import subprocess
|
|
23
|
+
import platform
|
|
24
|
+
import keyboard
|
|
25
|
+
import difflib
|
|
26
|
+
import getpass
|
|
27
|
+
import ctypes
|
|
28
|
+
import math
|
|
29
|
+
import json
|
|
30
|
+
import time
|
|
31
|
+
import sys
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
################################################## CUSTOM TYPES ##################################################
|
|
38
|
+
class rgb:
|
|
39
|
+
"""An RGB/RGBA color: is a tuple of 3 integers, representing the red (`0`-`255`), green (`0`-`255`), and blue (`0`-`255`).<br>
|
|
40
|
+
Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).\n
|
|
41
|
+
-------------------------------------------------------------------------------------------------------------------------------
|
|
42
|
+
Includes methods:
|
|
43
|
+
- `.to_hsl()` to convert to HSL color
|
|
44
|
+
- `.to_hex()` to convert to HEX color
|
|
45
|
+
- `.has_alpha()` to check if the color has an alpha channel"""
|
|
46
|
+
def __init__(self, r:int, g:int, b:int, a:float = None):
|
|
47
|
+
if any(isinstance(x, rgb) for x in (r, g, b)): raise ValueError('Color is already a rgb() color')
|
|
48
|
+
if not all(isinstance(x, int) and 0 <= x <= 255 for x in (r, g, b)): raise ValueError('RGB color must consist of 3 integers in [0, 255]')
|
|
49
|
+
if not a == None and not isinstance(a, (int, float)) and 0.0 <= float(a) <= 1.0: raise ValueError('Alpha channel must be a float in [0.0, 1.0]')
|
|
50
|
+
self.r, self.g, self.b, self.a = r, g, b, (1.0 if a > 1.0 else float(a)) if a else None
|
|
51
|
+
def __len__(self): return 4 if self.a else 3
|
|
52
|
+
def __iter__(self): return iter((self.r, self.g, self.b) + ((self.a,) if self.a else ()))
|
|
53
|
+
def __getitem__(self, index): return ((self.r, self.g, self.b) + ((self.a,) if self.a else ()))[index]
|
|
54
|
+
def __repr__(self): return f'rgb({self.r}, {self.g}, {self.b}{f', {self.a}' if self.a else ''})'
|
|
55
|
+
def __str__(self): return f'({self.r}, {self.g}, {self.b}{f', {self.a}' if self.a else ''})'
|
|
56
|
+
def __eq__(self, other):
|
|
57
|
+
if not isinstance(other, rgb): return False
|
|
58
|
+
return (self.r, self.g, self.b) == (other.r, other.g, other.b) and self.a == other.a
|
|
59
|
+
def list(self) -> list: return [self.r, self.g, self.b] + ([self.a] if self.a else [])
|
|
60
|
+
def tuple(self) -> tuple: return tuple(self.list())
|
|
61
|
+
def dict(self) -> dict: return dict(r=self.r, g=self.g, b=self.b, a=self.a) if self.a else dict(r=self.r, g=self.g, b=self.b)
|
|
62
|
+
def to_hsl(self) -> 'hsl': return hsl(Color.to_hsl(self))
|
|
63
|
+
def to_hex(self) -> 'hexa': return hexa(f'#{self.r:02X}{self.g:02X}{self.b:02X}{f'{int(self.a * 255):02X}' if self.a else ''}')
|
|
64
|
+
def has_alpha(self) -> bool: return self.a != None
|
|
65
|
+
|
|
66
|
+
class hsl:
|
|
67
|
+
"""A HSL/HSLA color: is a tuple of 3 integers, representing hue (`0`-`360`), saturation (`0`-`100`), and lightness (`0`-`100`).<br>
|
|
68
|
+
Also includes an optional 4th param, which is a float, that represents the alpha channel (`0.0`-`1.0`).\n
|
|
69
|
+
------------------------------------------------------------------------------------------------------------------------------------
|
|
70
|
+
Includes methods:
|
|
71
|
+
- `.to_rgb()` to convert to RGB color
|
|
72
|
+
- `.to_hex()` to convert to HEX color
|
|
73
|
+
- `.has_alpha()` to check if the color has an alpha channel"""
|
|
74
|
+
def __init__(self, h:int, s:int, l:int, a:float = None):
|
|
75
|
+
if any(isinstance(x, hsl) for x in (h, s, l)): raise ValueError('Color is already a hsl() color')
|
|
76
|
+
if not all(isinstance(x, int) for x in (h, s, l)) and (not (0 <= h <= 360) or not (0 <= s <= 100) or not (0 <= l <= 100)): raise ValueError('HSL color must have H in [0, 360] and S L in [0, 100]')
|
|
77
|
+
if not a == None and not isinstance(a, (int, float)) and 0.0 <= float(a) <= 1.0: raise ValueError('Alpha channel must be a float in [0.0, 1.0]')
|
|
78
|
+
self.h, self.s, self.l, self.a = h, s, l, (1.0 if a > 1.0 else float(a)) if a else None
|
|
79
|
+
def __len__(self): return 4 if self.a else 3
|
|
80
|
+
def __iter__(self): return iter((self.h, self.s, self.l) + ((self.a,) if self.a else ()))
|
|
81
|
+
def __getitem__(self, index): return ((self.h, self.s, self.l) + ((self.a,) if self.a else ()))[index]
|
|
82
|
+
def __repr__(self): return f'hsl({self.h}, {self.s}, {self.l}{f', {self.a}' if self.a else ''})'
|
|
83
|
+
def __str__(self): return f'({self.h}, {self.s}, {self.l}{f', {self.a}' if self.a else ''})'
|
|
84
|
+
def __eq__(self, other):
|
|
85
|
+
if not isinstance(other, hsl): return False
|
|
86
|
+
return (self.h, self.s, self.l) == (other.h, other.s, other.l) and self.a == other.a
|
|
87
|
+
def list(self) -> list: return [self.h, self.s, self.l] + ([self.a] if self.a else [])
|
|
88
|
+
def tuple(self) -> tuple: return tuple(self.list())
|
|
89
|
+
def dict(self) -> dict: return dict(h=self.h, s=self.s, l=self.l, a=self.a) if self.a else dict(h=self.h, s=self.s, l=self.l)
|
|
90
|
+
def to_rgb(self) -> 'rgb': return Color.to_rgb(self)
|
|
91
|
+
def to_hex(self) -> 'hexa': return self.to_rgb().to_hex()
|
|
92
|
+
def has_alpha(self) -> bool: return self.a != None
|
|
93
|
+
|
|
94
|
+
class hexa:
|
|
95
|
+
"""A HEX color: is a string representing a hexadecimal color code with optional alpha channel.\n
|
|
96
|
+
-------------------------------------------------------------------------------------------------
|
|
97
|
+
Supports formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (with or without leading #)<br>
|
|
98
|
+
Includes methods:
|
|
99
|
+
- `.to_rgb()` to convert to RGB color
|
|
100
|
+
- `.to_hsl()` to convert to HSL color
|
|
101
|
+
- `.has_alpha()` to check if the color has an alpha channel"""
|
|
102
|
+
def __init__(self, color:str):
|
|
103
|
+
if isinstance(color, hexa): raise ValueError('Color is already a hexa() color')
|
|
104
|
+
if not isinstance(color, str): raise TypeError('Color must be a string')
|
|
105
|
+
if not color.startswith('#'): color = f'#{color}'
|
|
106
|
+
color = color.upper()
|
|
107
|
+
if len(color) == 4: self.r, self.g, self.b, self.a = int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16), None #RGB
|
|
108
|
+
elif len(color) == 5: self.r, self.g, self.b, self.a = int(color[1] * 2, 16), int(color[2] * 2, 16), int(color[3] * 2, 16), int(color[4] * 2, 16) / 255.0 #RGBA
|
|
109
|
+
elif len(color) == 7: self.r, self.g, self.b, self.a = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16), None #RRGGBB
|
|
110
|
+
elif len(color) == 9: self.r, self.g, self.b, self.a = int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16), int(color[7:9], 16) / 255.0 #RRGGBBAA
|
|
111
|
+
else: raise ValueError('Hex color must be in format #RGB, #RGBA, #RRGGBB, or #RRGGBBAA')
|
|
112
|
+
def __len__(self): return 4 if self.a else 3
|
|
113
|
+
def __iter__(self): return iter((f'{self.r:02X}', f'{self.g:02X}', f'{self.b:02X}') + ((f'{int(self.a * 255):02X}',) if self.a else ()))
|
|
114
|
+
def __getitem__(self, index): return ((f'{self.r:02X}', f'{self.g:02X}', f'{self.b:02X}') + ((f'{int(self.a * 255):02X}',) if self.a else ()))[index]
|
|
115
|
+
def __repr__(self): return f'hexa(#{self.r:02X}{self.g:02X}{self.b:02X}{f'{int(self.a * 255):02X}' if self.a else ''})'
|
|
116
|
+
def __str__(self): return f'#{self.r:02X}{self.g:02X}{self.b:02X}{f'{int(self.a * 255):02X}' if self.a else ''}'
|
|
117
|
+
def __eq__(self, other):
|
|
118
|
+
if not isinstance(other, hexa): return False
|
|
119
|
+
return (self.r, self.g, self.b) == (other.r, other.g, other.b) and self.a == other.a
|
|
120
|
+
def list(self) -> list: return [f'{self.r:02X}', f'{self.g:02X}', f'{self.b:02X}'] + ([f'{int(self.a * 255):02X}'] if self.a else [])
|
|
121
|
+
def tuple(self) -> tuple: return tuple(self.list())
|
|
122
|
+
def dict(self) -> dict: return dict(r=f'{self.r:02X}', g=f'{self.g:02X}', b=f'{self.b:02X}', a=f'{int(self.a * 255):02X}') if self.a else dict(r=f'{self.r:02X}', g=f'{self.g:02X}', b=f'{self.b:02X}')
|
|
123
|
+
def to_rgb(self) -> 'rgb': return rgb(self.r, self.g, self.b)
|
|
124
|
+
def to_hsl(self) -> 'hsl': return hsl(Color.to_hsl(self.to_rgb()))
|
|
125
|
+
def has_alpha(self) -> bool: return self.a is not None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
################################################## DIRECTORY OPERATIONS ##################################################
|
|
130
|
+
|
|
131
|
+
class Dir:
|
|
132
|
+
@staticmethod
|
|
133
|
+
def get(cwd:bool = False, base_dir:bool = False) -> str|list:
|
|
134
|
+
paths = []
|
|
135
|
+
if cwd: paths.append(os.getcwd())
|
|
136
|
+
if base_dir:
|
|
137
|
+
if getattr(sys, 'frozen', False): base_dir = os.path.dirname(sys.executable)
|
|
138
|
+
elif hasattr(sys, '__file__'): base_dir = os.path.dirname(os.path.abspath(sys.modules['__main__'].__file__))
|
|
139
|
+
else: base_dir = os.path.dirname(os.path.abspath(__import__('__main__').__file__))
|
|
140
|
+
paths.append(base_dir)
|
|
141
|
+
return paths[0] if len(paths) == 1 else paths
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def extend_path(path:str, search_in:str|list[str] = None, raise_error:bool = False) -> str:
|
|
145
|
+
def get_closest_match(dir:str, part:str) -> str|None:
|
|
146
|
+
try:
|
|
147
|
+
files_and_dirs = os.listdir(dir)
|
|
148
|
+
matches = difflib.get_close_matches(part, files_and_dirs, n=1, cutoff=0.6)
|
|
149
|
+
return matches[0] if matches else None
|
|
150
|
+
except: return None
|
|
151
|
+
def find_path(start:str, parts:list[str]) -> str|None:
|
|
152
|
+
current = start
|
|
153
|
+
for part in parts:
|
|
154
|
+
if os.path.isfile(current): return current
|
|
155
|
+
closest_match = get_closest_match(current, part)
|
|
156
|
+
if closest_match: current = os.path.join(current, closest_match)
|
|
157
|
+
else: return None
|
|
158
|
+
return current if os.path.exists(current) and current != start else None
|
|
159
|
+
path = os.path.normpath(path)
|
|
160
|
+
if os.path.isabs(path):
|
|
161
|
+
drive, rel_path = os.path.splitdrive(path)
|
|
162
|
+
rel_path = rel_path.lstrip(os.sep)
|
|
163
|
+
search_dirs = [drive + os.sep] if drive else [os.sep]
|
|
164
|
+
else:
|
|
165
|
+
rel_path = path.lstrip(os.sep)
|
|
166
|
+
base_dir = Dir.get(base_dir=True)
|
|
167
|
+
search_dirs = [os.getcwd(), base_dir, os.path.expanduser('~'), '/tmp' if os.name != 'nt' else os.getenv('TEMP', '')]
|
|
168
|
+
if search_in: search_dirs.extend([search_in] if isinstance(search_in, str) else search_in)
|
|
169
|
+
path_parts = rel_path.split(os.sep)
|
|
170
|
+
for search_dir in search_dirs:
|
|
171
|
+
full_path = os.path.join(search_dir, rel_path)
|
|
172
|
+
if os.path.exists(full_path): return full_path
|
|
173
|
+
match = find_path(search_dir, path_parts)
|
|
174
|
+
if match: return match
|
|
175
|
+
if raise_error: raise FileNotFoundError(f'Path \'{path}\' not found in specified directories.')
|
|
176
|
+
return os.path.join(search_dirs[0], rel_path)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
################################################## FILE OPERATIONS ##################################################
|
|
181
|
+
|
|
182
|
+
class File:
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _get_path(filename:str, filetype:str, search_in:str|list[str] = None, prefer_base_dir:bool = True) -> str:
|
|
185
|
+
"""Get the path to a file in the cwd, the base-dir, or predefined directories.\n
|
|
186
|
+
--------------------------------------------------------------------------------------
|
|
187
|
+
If the `filename` is not found in the above directories, it will be searched<br>
|
|
188
|
+
in the `search_in` directory/directories. If the file is still not found, it will<br>
|
|
189
|
+
return the path to the file in the base-dir per default or to the file in the<br>
|
|
190
|
+
cwd if `prefer_base_dir` is set to `False`."""
|
|
191
|
+
if not filename.lower().endswith(f'.{filetype.lower()}'): filename = f'{filename}.{filetype.lower()}'
|
|
192
|
+
try: return Dir.extend_path(filename, search_in)
|
|
193
|
+
except FileNotFoundError: return os.path.join(Dir.get(base_dir=True), filename) if prefer_base_dir else os.path.join(os.getcwd(), filename)
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def rename_extension(file_path:str, new_extension:str) -> str:
|
|
197
|
+
directory, filename_with_ext = os.path.split(file_path)
|
|
198
|
+
filename = filename_with_ext.split('.')[0]
|
|
199
|
+
camel_case_filename = String.to_camel_case(filename)
|
|
200
|
+
new_filename = f'{camel_case_filename}{new_extension}'
|
|
201
|
+
new_file_path = os.path.join(directory, new_filename)
|
|
202
|
+
return new_file_path
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def create(content:str = '', file:str = 'new_file.txt', force:bool = False) -> str:
|
|
206
|
+
"""Create a file with ot without content.\n
|
|
207
|
+
----------------------------------------------------------------------------
|
|
208
|
+
The function will throw a `FileExistsError` if the file already exists.<br>
|
|
209
|
+
To overwrite the file, set the `force` parameter to `True`."""
|
|
210
|
+
if os.path.exists(file) and not force:
|
|
211
|
+
with open(file, 'r', encoding='utf-8') as existing_file:
|
|
212
|
+
existing_content = existing_file.read()
|
|
213
|
+
if existing_content == content: raise FileExistsError('Already created this file. (nothing changed)')
|
|
214
|
+
raise FileExistsError('File already exists.')
|
|
215
|
+
with open(file, 'w', encoding='utf-8') as f: f.write(content)
|
|
216
|
+
full_path = os.path.abspath(file)
|
|
217
|
+
return full_path
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
################################################## JSON-FILE OPERATIONS ##################################################
|
|
222
|
+
|
|
223
|
+
class Json:
|
|
224
|
+
@staticmethod
|
|
225
|
+
def read(json_file:str, comment_start:str = '>>', comment_end:str = '<<', return_original:bool = False) -> dict|tuple[dict,dict]:
|
|
226
|
+
"""Read JSON files, ignoring comments.\n
|
|
227
|
+
-------------------------------------------------------------------------
|
|
228
|
+
If only `comment_start` is found at the beginning of an item,<br>
|
|
229
|
+
the whole item is counted as a comment and therefore ignored.<br>
|
|
230
|
+
If `comment_start` and `comment_end` are found inside an item,<br>
|
|
231
|
+
the the section from `comment_start` to `comment_end` is ignored.<br>
|
|
232
|
+
If `return_original` is set to `True`, the original JSON is returned<br>
|
|
233
|
+
additionally. (returns: `[processed_json, original_json]`)"""
|
|
234
|
+
file_path = File._get_path(json_file, 'json')
|
|
235
|
+
with open(file_path, 'r') as file: content = file.read()
|
|
236
|
+
try: data = json.loads(content)
|
|
237
|
+
except json.JSONDecodeError as e: raise ValueError(f"Error parsing JSON in '{file_path}': {str(e)}")
|
|
238
|
+
processed_data = Data.remove_comments(data, comment_start, comment_end)
|
|
239
|
+
if not processed_data: raise ValueError(f"The JSON file '{file_path}' is empty or contains only comments.")
|
|
240
|
+
return (processed_data, data) if return_original else processed_data
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def create(content:dict, new_file:str = 'config', indent:int = 2, compactness:int = 1, force:bool = False) -> str:
|
|
244
|
+
file_path = File._get_path(new_file, 'json')
|
|
245
|
+
if os.path.exists(file_path) and not force:
|
|
246
|
+
with open(file_path, 'r', encoding='utf-8') as existing_file:
|
|
247
|
+
existing_content = json.load(existing_file)
|
|
248
|
+
if existing_content == content: raise FileExistsError(f'Already created this file. (nothing changed)')
|
|
249
|
+
raise FileExistsError('File already exists.')
|
|
250
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
251
|
+
f.write(Data.to_str(content, indent, compactness, as_json=True))
|
|
252
|
+
# json.dump(content, f, indent=indent)
|
|
253
|
+
full_path = os.path.abspath(file_path)
|
|
254
|
+
return full_path
|
|
255
|
+
|
|
256
|
+
@staticmethod
|
|
257
|
+
def update(json_file:str, update_values:str|list[str], comment_start:str = '>>', comment_end:str = '<<', sep:tuple[str,str] = ('->', '::')) -> None:
|
|
258
|
+
"""Function to easily update single/multiple values inside JSON files.\n
|
|
259
|
+
--------------------------------------------------------------------------------------------------------
|
|
260
|
+
The param `json_file` is the path to the JSON file or just the name of the JSON file to be updated.\n
|
|
261
|
+
--------------------------------------------------------------------------------------------------------
|
|
262
|
+
The param `update_values` is a sort of path (or a list of paths) to the value/s to be updated, with<br>
|
|
263
|
+
the new value at the end of the path.<br>
|
|
264
|
+
In this example:
|
|
265
|
+
```\n {
|
|
266
|
+
'healthy': {
|
|
267
|
+
'fruit': ['apples', 'bananas', 'oranges'],
|
|
268
|
+
'vegetables': ['carrots', 'broccoli', 'celery']
|
|
269
|
+
}
|
|
270
|
+
}\n```
|
|
271
|
+
... if you want to change the value of `'apples'` to `'strawberries'`, `update_values` would<br>
|
|
272
|
+
be `healthy->fruit->apples::strawberries` or if you don't know that the value to update<br>
|
|
273
|
+
is `apples` you can also use the position of the value, so `healthy->fruit->0::strawberries`.\n
|
|
274
|
+
⇾ **If the path from `update_values` doesn't exist, it will be created.**\n
|
|
275
|
+
--------------------------------------------------------------------------------------------------------
|
|
276
|
+
If only `comment_start` is found at the beginning of an item, the whole item is counted<br>
|
|
277
|
+
as a comment and therefore ignored. If `comment_start` and `comment_end` are found<br>
|
|
278
|
+
inside an item, the the section from `comment_start` to `comment_end` is ignored."""
|
|
279
|
+
if isinstance(update_values, str): update_values = [update_values]
|
|
280
|
+
valid_entries = [(parts[0].strip(), parts[1]) for update_value in update_values
|
|
281
|
+
if len(parts := update_value.split(str(sep[1]).strip())) == 2]
|
|
282
|
+
value_paths, new_values = (zip(*valid_entries) if valid_entries else ([], []))
|
|
283
|
+
processed_data, data = Json.read(json_file, comment_start, comment_end, return_original=True)
|
|
284
|
+
update = []
|
|
285
|
+
for value_path, new_value in zip(value_paths, new_values):
|
|
286
|
+
path_id = Data.get_path_id(processed_data, value_path)
|
|
287
|
+
update.append(f'{path_id}::{new_value}')
|
|
288
|
+
updated = Data.set_value_by_path_id(data, update)
|
|
289
|
+
Json.create(updated, json_file, force=True)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
################################################## SYSTEM ACTIONS ##################################################
|
|
294
|
+
|
|
295
|
+
class System:
|
|
296
|
+
@staticmethod
|
|
297
|
+
def restart(msg:str = None, wait:int = 0, continue_program:bool = False, force:bool = False) -> None:
|
|
298
|
+
system = platform.system().lower()
|
|
299
|
+
if system == 'windows':
|
|
300
|
+
if not force:
|
|
301
|
+
output = subprocess.check_output('tasklist', shell=True).decode()
|
|
302
|
+
processes = [line.split()[0] for line in output.splitlines()[3:] if line.strip()]
|
|
303
|
+
if len(processes) > 2: # EXCLUDING THE PYTHON PROCESS AND CMD
|
|
304
|
+
raise RuntimeError('Processes are still running. Use the parameter `force=True` to restart anyway.')
|
|
305
|
+
if msg: os.system(f'shutdown /r /t {wait} /c "{msg}"')
|
|
306
|
+
else: os.system('shutdown /r /t 0')
|
|
307
|
+
if continue_program:
|
|
308
|
+
print(f'Restarting in {wait} seconds...')
|
|
309
|
+
time.sleep(wait)
|
|
310
|
+
elif system in ['linux', 'darwin']:
|
|
311
|
+
if not force:
|
|
312
|
+
output = subprocess.check_output(['ps', '-A']).decode()
|
|
313
|
+
processes = output.splitlines()[1:] # EXCLUDE HEADER
|
|
314
|
+
if len(processes) > 2: # EXCLUDING THE PYTHON PROCESS AND PS
|
|
315
|
+
raise RuntimeError('Processes are still running. Use the parameter `force=True` to restart anyway.')
|
|
316
|
+
if msg:
|
|
317
|
+
subprocess.Popen(['notify-send', 'System Restart', msg])
|
|
318
|
+
time.sleep(wait)
|
|
319
|
+
subprocess.run(['sudo', 'shutdown', '-r', 'now'])
|
|
320
|
+
if continue_program:
|
|
321
|
+
print(f'Restarting in {wait} seconds...')
|
|
322
|
+
time.sleep(wait)
|
|
323
|
+
else: raise NotImplementedError(f'Restart not implemented for `{system}`')
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
################################################## PATH ACTIONS ##################################################
|
|
328
|
+
|
|
329
|
+
class Env_vars:
|
|
330
|
+
"""Functions for managing the systems environment-variables:
|
|
331
|
+
- `Env_vars.get_paths()`
|
|
332
|
+
- `Env_vars.has_path()`
|
|
333
|
+
- `Env_vars.add_path()`"""
|
|
334
|
+
@staticmethod
|
|
335
|
+
def get_paths(as_list:bool = False) -> str|list:
|
|
336
|
+
paths = os.environ.get('PATH')
|
|
337
|
+
return paths.split(os.pathsep) if as_list else paths
|
|
338
|
+
|
|
339
|
+
@staticmethod
|
|
340
|
+
def has_path(path:str = None, cwd:bool = False, base_dir:bool = False) -> bool:
|
|
341
|
+
if cwd: path = os.getcwd()
|
|
342
|
+
if base_dir: path = Dir.get(base_dir=True)
|
|
343
|
+
paths = Env_vars.get_paths()
|
|
344
|
+
return path in paths
|
|
345
|
+
|
|
346
|
+
@staticmethod
|
|
347
|
+
def __add_sort_paths(add_path:str, current_paths:str) -> str:
|
|
348
|
+
final_paths = Data.remove_empty_items(Data.remove_duplicates(f'{add_path};{current_paths}'.split(os.pathsep)))
|
|
349
|
+
final_paths.sort()
|
|
350
|
+
return f'{os.pathsep.join(final_paths)};'
|
|
351
|
+
|
|
352
|
+
@staticmethod
|
|
353
|
+
def add_path(add_path:str = None, cwd:bool = False, base_dir:bool = False, persistent:bool = True) -> None:
|
|
354
|
+
if cwd: add_path = os.getcwd()
|
|
355
|
+
if base_dir: add_path = Dir.get(base_dir=True)
|
|
356
|
+
if not Env_vars.has_path(add_path):
|
|
357
|
+
final_paths = Env_vars.__add_sort_paths(add_path, Env_vars.get_paths())
|
|
358
|
+
os.environ['PATH'] = final_paths
|
|
359
|
+
if persistent:
|
|
360
|
+
try:
|
|
361
|
+
import winreg
|
|
362
|
+
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_ALL_ACCESS)
|
|
363
|
+
winreg.SetValueEx(key, 'PATH', 0, winreg.REG_EXPAND_SZ, final_paths)
|
|
364
|
+
winreg.CloseKey(key)
|
|
365
|
+
except ImportError: raise ImportError('Unable to make persistent changes. This method only works on Windows.')
|
|
366
|
+
else: raise ValueError(f'{add_path} is already in PATH.')
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
################################################## CMD LOG & ACTIONS ##################################################
|
|
371
|
+
|
|
372
|
+
class Cmd:
|
|
373
|
+
"""Functions for logging and other small actions within the console:
|
|
374
|
+
- `Cmd.get_args()`
|
|
375
|
+
- `Cmd.user()`
|
|
376
|
+
- `Cmd.is_admin()`
|
|
377
|
+
- `Cmd.pause_exit()`
|
|
378
|
+
- `Cmd.cls()`
|
|
379
|
+
- `Cmd.log()`
|
|
380
|
+
- `Cmd.info()`
|
|
381
|
+
- `Cmd.done()`
|
|
382
|
+
- `Cmd.warn()`
|
|
383
|
+
- `Cmd.fail()`
|
|
384
|
+
- `Cmd.exit()`
|
|
385
|
+
- `Cmd.confirm()`\n
|
|
386
|
+
----------------------------------------------------------------------------------------------------------
|
|
387
|
+
You can also use special formatting codes directly inside the log message to change their appearance.<br>
|
|
388
|
+
For more detailed information about formatting codes, see the `log` class description."""
|
|
389
|
+
@staticmethod
|
|
390
|
+
def get_args(find_args:dict) -> dict:
|
|
391
|
+
args = sys.argv[1:]
|
|
392
|
+
results = {}
|
|
393
|
+
for arg_key, arg_group in find_args.items():
|
|
394
|
+
value = None
|
|
395
|
+
exists = False
|
|
396
|
+
for arg in arg_group:
|
|
397
|
+
if arg in args:
|
|
398
|
+
exists = True
|
|
399
|
+
arg_index = args.index(arg)
|
|
400
|
+
if arg_index + 1 < len(args) and not args[arg_index + 1].startswith('-'): value = String.to_type(args[arg_index + 1])
|
|
401
|
+
break
|
|
402
|
+
results[arg_key] = {'exists': exists, 'value': value}
|
|
403
|
+
return results
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def user() -> str:
|
|
407
|
+
return os.getenv('USER') or getpass.getuser()
|
|
408
|
+
|
|
409
|
+
@staticmethod
|
|
410
|
+
def is_admin() -> bool:
|
|
411
|
+
try: return ctypes.windll.shell32.IsUserAnAdmin() in [1, True]
|
|
412
|
+
except AttributeError: return False
|
|
413
|
+
|
|
414
|
+
@staticmethod
|
|
415
|
+
def pause_exit(pause:bool = False, exit:bool = False, last_msg:str = '', exit_code:int = 0, reset_ansi:bool = False) -> None:
|
|
416
|
+
print(last_msg, end='', flush=True)
|
|
417
|
+
if reset_ansi: FormatCodes.print('[_]', end='')
|
|
418
|
+
if pause: keyboard.read_event()
|
|
419
|
+
if exit: sys.exit(exit_code)
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def cls() -> None:
|
|
423
|
+
"""Will clear the console in addition to completely resetting the ANSI formats."""
|
|
424
|
+
print('\033[0m', end='', flush=True)
|
|
425
|
+
os.system('cls|clear')
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def log(title:str, msg:str, start:str = '', end:str = '\n', title_bg_color:hexa|rgb = None, default_color:hexa|rgb = None) -> None:
|
|
429
|
+
title_color = '_color' if not title_bg_color else Color.text_color_for_on_bg(title_bg_color)
|
|
430
|
+
if title: FormatCodes.print(f'{start} [bold][{title_color}]{f'[BG:{title_bg_color}]' if title_bg_color else ''} {title.upper()}: [_]\t{f'[{default_color}]' if default_color else ''}{str(msg)}[_]', default_color, end=end)
|
|
431
|
+
else: FormatCodes.print(f'{start} {f'[{default_color}]' if default_color else ''}{str(msg)}[_]', default_color, end=end)
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def debug(msg:str = 'Point in program reached.', start:str = '\n', end:str = '\n\n', title_bg_color:hexa|rgb = '#FFD260', default_color:hexa|rgb = '#9AB8F7', pause:bool = False, exit:bool = False) -> None:
|
|
435
|
+
Cmd.log('DEBUG', msg, start, end, title_bg_color, default_color)
|
|
436
|
+
Cmd.pause_exit(pause, exit)
|
|
437
|
+
|
|
438
|
+
@staticmethod
|
|
439
|
+
def info(msg:str = 'Program running.', start:str = '\n', end:str = '\n\n', title_bg_color:hexa|rgb = '#77F', default_color:hexa|rgb = '#9AB8F7', pause:bool = False, exit:bool = False) -> None:
|
|
440
|
+
Cmd.log('INFO', msg, start, end, title_bg_color, default_color)
|
|
441
|
+
Cmd.pause_exit(pause, exit)
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def done(msg:str = 'Program finished.', start:str = '\n', end:str = '\n\n', title_bg_color:hexa|rgb = '#49EAB7', default_color:hexa|rgb = '#9AB8F7', pause:bool = False, exit:bool = False) -> None:
|
|
445
|
+
Cmd.log('DONE', msg, start, end, title_bg_color, default_color)
|
|
446
|
+
Cmd.pause_exit(pause, exit)
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def warn(msg:str = 'Important message.', start:str = '\n', end:str = '\n\n', title_bg_color:hexa|rgb = '#FF8C60', default_color:hexa|rgb = '#9AB8F7', pause:bool = False, exit:bool = True) -> None:
|
|
450
|
+
Cmd.log('WARN', msg, start, end, title_bg_color, default_color)
|
|
451
|
+
Cmd.pause_exit(pause, exit)
|
|
452
|
+
|
|
453
|
+
@staticmethod
|
|
454
|
+
def fail(msg:str = 'Program error.', start:str = '\n', end:str = '\n\n', title_bg_color:hexa|rgb = '#FF606A', default_color:hexa|rgb = '#9AB8F7', pause:bool = False, exit:bool = True, reset_ansi=True) -> None:
|
|
455
|
+
Cmd.log('FAIL', msg, start, end, title_bg_color, default_color)
|
|
456
|
+
Cmd.pause_exit(pause, exit, reset_ansi=reset_ansi)
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def exit(msg:str = 'Program ended.', start:str = '\n', end:str = '\n\n', title_bg_color:hexa|rgb = '#C860FF', default_color:hexa|rgb = '#9AB8F7', pause:bool = False, exit:bool = True, reset_ansi=True) -> None:
|
|
460
|
+
Cmd.log('EXIT', msg, start, end, title_bg_color, default_color)
|
|
461
|
+
Cmd.pause_exit(pause, exit, reset_ansi=reset_ansi)
|
|
462
|
+
|
|
463
|
+
@staticmethod
|
|
464
|
+
def confirm(msg:str = 'Are you sure?[_|dim] [Y/N]: [_]', start = '\n', end = '\n', default_color:hexa|rgb = '#3EE6DE', default_is_yes:bool = False) -> None:
|
|
465
|
+
confirmed = input(FormatCodes.to_ansi(f'{start} {str(msg)}', default_color)).strip().upper() in (('', 'Y', 'YES') if default_is_yes else ('Y', 'YES'))
|
|
466
|
+
if end: Cmd.log('', '') if end == '\n' else Cmd.log('', end[1:]) if end.startswith('\n') else Cmd.log('', end)
|
|
467
|
+
return confirmed
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
################################################## ANSI-/PRETTY PRINTING ##################################################
|
|
472
|
+
|
|
473
|
+
class FormatCodes:
|
|
474
|
+
"""Functions to be able to use special (easy) formatting codes directly inside some message (string).<br>
|
|
475
|
+
These codes, when used within following functions, will change the look of log within the console:
|
|
476
|
+
- `FormatCodes.print()`
|
|
477
|
+
- `FormatCodes.to_ansi()`\n
|
|
478
|
+
--------------------------------------------------------------------------------------------------------------------
|
|
479
|
+
How to change the text format and color?<br>
|
|
480
|
+
**Example string with formatting codes:**<br>
|
|
481
|
+
> `[bold]This is bold text, [#F08]which is pink now [black|BG:#FF0088] and now it changed`<br>
|
|
482
|
+
> `to black with a pink background. [_]And this is the boring text, where everything is reset.`\n
|
|
483
|
+
⇾ **Instead of writing the formats all separate** `[…][…][…]` **you can join them like this** `[…|…|…]`\n
|
|
484
|
+
--------------------------------------------------------------------------------------------------------------------
|
|
485
|
+
You can also automatically reset a certain format, behind text like shown in the following example:<br>
|
|
486
|
+
> `This is normal text [b](which is bold now) but now it was automatically reset to normal.`\n
|
|
487
|
+
This will only reset formats, that have a reset listed below. Colors and BG-colors won't be reset.<br>
|
|
488
|
+
This is what will happen, if you use it with a color-format:<br>
|
|
489
|
+
> `[cyan]This is cyan text [b](which is bold now.) Now it's not bold any more but still cyan.`\n
|
|
490
|
+
⇾ **To see these examples in action, you can put them into the** `FormatCodes.print()` **function.**\n
|
|
491
|
+
--------------------------------------------------------------------------------------------------------------------
|
|
492
|
+
**All possible formatting codes:**
|
|
493
|
+
- HEX colors: `[#F08]` or `[#FF0088]` (with or without leading #)
|
|
494
|
+
- RGB colors: `[rgb(255, 0, 136)]`
|
|
495
|
+
- bright colors: `[bright:#F08]`
|
|
496
|
+
- background colors: `[BG:#F08]`
|
|
497
|
+
- standard cmd colors:
|
|
498
|
+
- `[black]`
|
|
499
|
+
- `[red]`
|
|
500
|
+
- `[green]`
|
|
501
|
+
- `[yellow]`
|
|
502
|
+
- `[blue]`
|
|
503
|
+
- `[magenta]`
|
|
504
|
+
- `[cyan]`
|
|
505
|
+
- `[white]`
|
|
506
|
+
- bright cmd colors: `[bright:black]` or `[br:black]`, `[bright:red]` or `[br:red]`, ...
|
|
507
|
+
- background cmd colors: `[BG:black]`, `[BG:red]`, ...
|
|
508
|
+
- bright background cmd colors: `[BG:bright:black]` or `[BG:br:black]`, `[BG:bright:red]` or `[BG:br:red]`, ...<br>
|
|
509
|
+
⇾ **The order of** `BG:` **and** `bright:` or `br:` **does not matter.**
|
|
510
|
+
- text formats:
|
|
511
|
+
- `[bold]` or `[b]`
|
|
512
|
+
- `[dim]`
|
|
513
|
+
- `[italic]` or `[i]`
|
|
514
|
+
- `[underline]` or `[u]`
|
|
515
|
+
- `[inverse]` or `[in]`
|
|
516
|
+
- `[hidden]` or `[h]`
|
|
517
|
+
- `[strikethrough]` or `[s]`
|
|
518
|
+
- `[double-underline]` or `[du]`
|
|
519
|
+
- specific reset: `[_bold]` or `[_b]`, `[_dim]`, ... or `[_color]` or `[_c]`, `[_background]` or `[_bg]`
|
|
520
|
+
- total reset: `[_]` (only if no `default_color` is set, otherwise see **↓** )
|
|
521
|
+
--------------------------------------------------------------------------------------------------------------------
|
|
522
|
+
**Special formatting when param `default_color` is set to a color:**
|
|
523
|
+
- `[*]` will reset everything, just like `[_]`, but the text-color will remain in `default_color`
|
|
524
|
+
- `[*color]` will reset the text-color, just like `[_color]`, but then also make it `default_color`
|
|
525
|
+
- `[default]` will just color the text in `default_color`,
|
|
526
|
+
- `[BG:default]` will color the background in `default_color`\n
|
|
527
|
+
Unlike the standard cmd colors, the default color can be changed by using the following modifiers:
|
|
528
|
+
- `[l]` will lighten the `default_color` text by `brightness_steps`%
|
|
529
|
+
- `[ll]` will lighten the `default_color` text by `2 × brightness_steps`%
|
|
530
|
+
- `[lll]` will lighten the `default_color` text by `3 × brightness_steps`%
|
|
531
|
+
- ... etc. Same thing for darkening:
|
|
532
|
+
- `[d]` will darken the `default_color` text by `brightness_steps`%
|
|
533
|
+
- `[dd]` will darken the `default_color` text by `2 × brightness_steps`%
|
|
534
|
+
- `[ddd]` will darken the `default_color` text by `3 × brightness_steps`%
|
|
535
|
+
- ... etc.\n
|
|
536
|
+
Per default, you can also use `+` and `-` to get lighter and darker `default_color` versions.<br>
|
|
537
|
+
This can also be changed by changing the param `_modifiers = ('+l', '-d')`."""
|
|
538
|
+
ansi_pref = '\033['
|
|
539
|
+
codes_map = {
|
|
540
|
+
'_':'0m', ('_bold', '_b'):'22m', '_dim':'22m', ('_italic', '_i'):'23m', ('_underline', '_u'):'24m', ('_double-underline', '_du'):'24m', ('_inverse', '_in'):'27m', ('_hidden', '_h'):'28m', ('_strikethrough', '_s'):'29m', ('_color', '_c'):'39m', ('_background', '_bg'):'49m',
|
|
541
|
+
('bold', 'b'):'1m', 'dim':'2m', ('italic', 'i'):'3m', ('underline', 'u'):'4m', ('inverse', 'in'):'7m', ('hidden', 'h'):'8m', ('strikethrough', 's'):'9m', ('double-underline', 'du'):'21m',
|
|
542
|
+
'black':'30m', 'red':'31m', 'green':'32m', 'yellow':'33m', 'blue':'34m', 'magenta':'35m', 'cyan':'36m', 'white':'37m',
|
|
543
|
+
('bright:black', 'br:black'):'90m', ('bright:red', 'br:red'):'91m', ('bright:green', 'br:green'):'92m', ('bright:yellow', 'br:yellow'):'93m', ('bright:blue', 'br:blue'):'94m', ('bright:magenta', 'br:magenta'):'95m', ('bright:cyan', 'br:cyan'):'96m', ('bright:white', 'br:white'):'97m',
|
|
544
|
+
'bg:black':'40m', 'bg:red':'41m', 'bg:green':'42m', 'bg:yellow':'43m', 'bg:blue':'44m', 'bg:magenta':'45m', 'bg:cyan':'46m', 'bg:white':'47m',
|
|
545
|
+
('bg:bright:black', 'bg:br:black'):'100m', ('bg:bright:red', 'bg:br:red'):'101m', ('bg:bright:green', 'bg:br:green'):'102m', ('bg:bright:yellow', 'bg:br:yellow'):'103m', ('bg:bright:blue', 'bg:br:blue'):'104m', ('bg:bright:magenta', 'bg:br:magenta'):'105m', ('bg:bright:cyan', 'bg:br:cyan'):'106m', ('bg:bright:white', 'bg:br:white'):'107m',
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
@staticmethod
|
|
549
|
+
def print(prompt:str, default_color:hexa|rgb = None, brightness_steps:int = 20, sep:str = ' ', end:str = '\n') -> None:
|
|
550
|
+
sys.stdout.flush()
|
|
551
|
+
kernel32 = ctypes.windll.kernel32
|
|
552
|
+
h = kernel32.GetStdHandle(-11)
|
|
553
|
+
mode = ctypes.c_ulong()
|
|
554
|
+
kernel32.GetConsoleMode(h, ctypes.byref(mode))
|
|
555
|
+
kernel32.SetConsoleMode(h, mode.value | 0x0004) # ENABLE VIRTUAL TERMINAL PROCESSING
|
|
556
|
+
ansi_prompt = FormatCodes.to_ansi(prompt, default_color, brightness_steps)
|
|
557
|
+
print(ansi_prompt, sep=sep, end=end, flush=True)
|
|
558
|
+
|
|
559
|
+
@staticmethod
|
|
560
|
+
def to_ansi(string:str, default_color:hexa|rgb = None, brightness_steps:int = 20) -> str:
|
|
561
|
+
result, use_default = '', default_color and (Color.is_valid_rgb(default_color) or Color.is_valid_hex(default_color))
|
|
562
|
+
if use_default:
|
|
563
|
+
string = re.sub(r'\[\s*([^]_]*?)\s*\*\s*([^]_]*?)\]', r'[\1_|default\2]', string) # REPLACE `[…|*|…]` WITH `[…|_|default|…]`
|
|
564
|
+
string = re.sub(r'\[\s*([^]_]*?)\s*\*color\s*([^]_]*?)\]', r'[\1default\2]', string) # REPLACE `[…|*color|…]` WITH `[…|default|…]`
|
|
565
|
+
def replace_keys(match:rx.Match) -> str:
|
|
566
|
+
format_keys, auto_reset_txt = match.group(1), match.group(2)
|
|
567
|
+
if not format_keys: return match.group(0)
|
|
568
|
+
else:
|
|
569
|
+
format_keys = [k.strip() for k in format_keys.split('|') if k.strip()]
|
|
570
|
+
ansi_resets, ansi_formats = [], [FormatCodes.__get_replacement(k, default_color, brightness_steps) for k in format_keys]
|
|
571
|
+
if auto_reset_txt:
|
|
572
|
+
reset_keys = [f'_{k}' for k in format_keys]
|
|
573
|
+
ansi_resets = [r for k in reset_keys if (r := FormatCodes.__get_replacement(k, default_color, brightness_steps)).startswith(FormatCodes.ansi_pref)]
|
|
574
|
+
if not all(f.startswith(FormatCodes.ansi_pref) for f in ansi_formats): return match.group(0)
|
|
575
|
+
return ''.join(ansi_formats) + (auto_reset_txt if auto_reset_txt else '') + ''.join(ansi_resets)
|
|
576
|
+
result = '\n'.join(rx.sub(Regex.brackets('[', ']', is_group=True) + r'(?:\s*' + Regex.brackets('(', ')', is_group=True) + r')?', replace_keys, line) for line in string.splitlines())
|
|
577
|
+
return FormatCodes.__get_default_ansi(default_color) + result if use_default else result
|
|
578
|
+
|
|
579
|
+
@staticmethod
|
|
580
|
+
def __get_default_ansi(default_color:hexa|rgb, format_key:str = None, brightness_steps:int = None, _modifiers:tuple[str, str] = ('+l', '-d')) -> str:
|
|
581
|
+
if Color.is_valid_hex(default_color): default_color = Color.to_rgb(default_color)
|
|
582
|
+
if not brightness_steps or (format_key and re.search(r'(?i)((?:BG\s*:)?)\s*default', format_key)):
|
|
583
|
+
if format_key and re.search(r'(?i)BG\s*:\s*default', format_key): return f'{FormatCodes.ansi_pref}48;2;{default_color[0]};{default_color[1]};{default_color[2]}m'
|
|
584
|
+
return f'{FormatCodes.ansi_pref}38;2;{default_color[0]};{default_color[1]};{default_color[2]}m'
|
|
585
|
+
match = re.match(rf'(?i)((?:BG\s*:)?)\s*({'|'.join([f'{re.escape(m)}+' for m in _modifiers[0] + _modifiers[1]])})$', format_key)
|
|
586
|
+
if not match or not match.group(2): return
|
|
587
|
+
is_bg, modifier = match.group(1), match.group(2)
|
|
588
|
+
new_rgb, lighten, darken = None, None, None
|
|
589
|
+
for mod in _modifiers[0]:
|
|
590
|
+
lighten = String.get_repeated_symbol(modifier, mod)
|
|
591
|
+
if lighten and lighten > 0:
|
|
592
|
+
new_rgb = Color.adjust_brightness(default_color, (brightness_steps / 100) * lighten)
|
|
593
|
+
break
|
|
594
|
+
if not new_rgb:
|
|
595
|
+
for mod in _modifiers[1]:
|
|
596
|
+
darken = String.get_repeated_symbol(modifier, mod)
|
|
597
|
+
if darken and darken > 0:
|
|
598
|
+
print(-(brightness_steps / 100) * darken)
|
|
599
|
+
new_rgb = Color.adjust_brightness(default_color, -(brightness_steps / 100) * darken)
|
|
600
|
+
break
|
|
601
|
+
if new_rgb: return f'{FormatCodes.ansi_pref}48;2;{new_rgb[0]};{new_rgb[1]};{new_rgb[2]}m' if is_bg else f'{FormatCodes.ansi_pref}38;2;{new_rgb[0]};{new_rgb[1]};{new_rgb[2]}m'
|
|
602
|
+
|
|
603
|
+
@staticmethod
|
|
604
|
+
def __get_replacement(format_key:str, default_color:hexa|rgb = None, brightness_steps:int = 20, _modifiers:tuple[str, str] = ('+l', '-d')) -> str:
|
|
605
|
+
"""Gives you the corresponding ANSI code for the given format key.<br>
|
|
606
|
+
If `default_color` is not `None`, the text color will be `default_color` if all formats<br>
|
|
607
|
+
are reset or you can get lighter or darker version of `default_color` (also as BG) by<br>
|
|
608
|
+
using one or more `_modifiers` symbols as a format key ()"""
|
|
609
|
+
def key_exists(key:str) -> bool:
|
|
610
|
+
for map_key in FormatCodes.codes_map:
|
|
611
|
+
if isinstance(map_key, tuple) and key in map_key: return True
|
|
612
|
+
elif key == map_key: return True
|
|
613
|
+
return False
|
|
614
|
+
def get_value(key:str) -> any:
|
|
615
|
+
for map_key in FormatCodes.codes_map:
|
|
616
|
+
if isinstance(map_key, tuple) and key in map_key: return FormatCodes.codes_map[map_key]
|
|
617
|
+
elif key == map_key: return FormatCodes.codes_map[map_key]
|
|
618
|
+
return None
|
|
619
|
+
use_default = default_color and (Color.is_valid_rgb(default_color) or Color.is_valid_hex(default_color))
|
|
620
|
+
_format_key, format_key = format_key, FormatCodes.__normalize(format_key)
|
|
621
|
+
if use_default:
|
|
622
|
+
new_default_color = FormatCodes.__get_default_ansi(default_color, format_key, brightness_steps, _modifiers)
|
|
623
|
+
if new_default_color: return new_default_color
|
|
624
|
+
if key_exists(format_key): return FormatCodes.ansi_pref + get_value(format_key)
|
|
625
|
+
rgb_match = re.match(r'(?i)\s*(BG\s*:)?\s*(rgb)?\s*\(?\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)?\s*', format_key)
|
|
626
|
+
hex_match = re.match(r'(?i)\s*(BG\s*:)?\s*#?([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{4}|[0-9A-F]{3})\s*', format_key)
|
|
627
|
+
try:
|
|
628
|
+
if rgb_match:
|
|
629
|
+
is_bg = rgb_match.group(1)
|
|
630
|
+
r, g, b = map(int, rgb_match.groups()[2:])
|
|
631
|
+
if Color.is_valid_rgb((r, g, b)): return f'{FormatCodes.ansi_pref}48;2;{r};{g};{b}m' if is_bg else f'{FormatCodes.ansi_pref}38;2;{r};{g};{b}m'
|
|
632
|
+
elif hex_match:
|
|
633
|
+
is_bg = hex_match.group(1)
|
|
634
|
+
rgb = Color.to_rgb(hex_match.group(2))
|
|
635
|
+
return f'{FormatCodes.ansi_pref}48;2;{rgb[0]};{rgb[1]};{rgb[2]}m' if is_bg else f'{FormatCodes.ansi_pref}38;2;{rgb[0]};{rgb[1]};{rgb[2]}m'
|
|
636
|
+
except Exception: pass
|
|
637
|
+
return _format_key
|
|
638
|
+
|
|
639
|
+
@staticmethod
|
|
640
|
+
def __normalize(format_key:str) -> str:
|
|
641
|
+
"""Put the given format key in the correct format:<br>
|
|
642
|
+
`1` put `BG:` as first key-part<br>
|
|
643
|
+
`2` put `bright:` or `br:` as second key-part<br>
|
|
644
|
+
`3` put everything else behind<br>
|
|
645
|
+
`4` everything in lower case"""
|
|
646
|
+
format_key = format_key.replace(' ', '').lower().strip()
|
|
647
|
+
if ':' in format_key:
|
|
648
|
+
key_parts = format_key.split(':')
|
|
649
|
+
format_key = ('bg:' if 'bg' in key_parts else '') + ('bright:' if 'bright' in key_parts or 'br' in key_parts else '') + ''.join(Data.remove(key_parts, ['bg', 'bright', 'br']))
|
|
650
|
+
return format_key
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
################################################## COLOR OPERATIONS ##################################################
|
|
655
|
+
|
|
656
|
+
class Color:
|
|
657
|
+
@staticmethod
|
|
658
|
+
def has_alpha(color:rgb|hsl|hexa) -> bool:
|
|
659
|
+
"""Check if the given color has an alpha channel.\n
|
|
660
|
+
--------------------------------------------------------------------------------
|
|
661
|
+
Input a HEX or RGB color as `color`.<br>
|
|
662
|
+
Returns `True` if the color has an alpha channel and `False` otherwise."""
|
|
663
|
+
if isinstance(color, (rgb, hsl, hexa)): return color.has_alpha()
|
|
664
|
+
if isinstance(color, str) and Color.is_valid_hex(color, has_alpha=True): return len(color.lstrip('#')) == 4 or len(color.lstrip('#')) == 8
|
|
665
|
+
elif isinstance(color, (list, tuple, dict)) and len(color) == 4: return True
|
|
666
|
+
else: return False
|
|
667
|
+
|
|
668
|
+
@staticmethod
|
|
669
|
+
def is_valid_rgb(color:str|list|tuple|dict, allow_alpha:bool = False) -> bool:
|
|
670
|
+
try:
|
|
671
|
+
if isinstance(color, (list, tuple, rgb)):
|
|
672
|
+
if allow_alpha and Color.has_alpha(color): return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255 and 0 <= color[3] <= 255
|
|
673
|
+
else: return 0 <= color[0] <= 255 and 0 <= color[1] <= 255 and 0 <= color[2] <= 255
|
|
674
|
+
elif isinstance(color, dict):
|
|
675
|
+
if allow_alpha and Color.has_alpha(color): return 0 <= color['r'] <= 255 and 0 <= color['g'] <= 255 and 0 <= color['b'] <= 255 and 0 <= color['a'] <= 255
|
|
676
|
+
else: return 0 <= color['r'] <= 255 and 0 <= color['g'] <= 255 and 0 <= color['b'] <= 255
|
|
677
|
+
elif isinstance(color, str): return bool(re.fullmatch(Regex.rgb_str(), color))
|
|
678
|
+
except: return False
|
|
679
|
+
|
|
680
|
+
@staticmethod
|
|
681
|
+
def is_valid_hsl(color:str|list|tuple|dict, allow_alpha:bool = False) -> bool:
|
|
682
|
+
try:
|
|
683
|
+
if isinstance(color, (list, tuple, hsl)):
|
|
684
|
+
if allow_alpha and Color.has_alpha(color): return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100 and 0.0 <= color[3] <= 1.0
|
|
685
|
+
else: return 0 <= color[0] <= 360 and 0 <= color[1] <= 100 and 0 <= color[2] <= 100
|
|
686
|
+
elif isinstance(color, dict):
|
|
687
|
+
if allow_alpha and Color.has_alpha(color): return 0 <= color['h'] <= 360 and 0 <= color['s'] <= 100 and 0 <= color['l'] <= 100 and 0.0 <= color['a'] <= 1.0
|
|
688
|
+
else: return 0 <= color['h'] <= 360 and 0 <= color['s'] <= 100 and 0 <= color['l'] <= 100
|
|
689
|
+
elif isinstance(color, str): return bool(re.fullmatch(Regex.hsl_str(), color))
|
|
690
|
+
except: return False
|
|
691
|
+
|
|
692
|
+
@staticmethod
|
|
693
|
+
def is_valid_hex(color:str, allow_alpha:bool = False) -> bool:
|
|
694
|
+
try:
|
|
695
|
+
if allow_alpha: pattern = r'(?i)^([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{4}|[0-9A-F]{3})$'
|
|
696
|
+
else: pattern = r'(?i)^([0-9A-F]{6}|[0-9A-F]{3})$'
|
|
697
|
+
return bool(re.fullmatch(pattern, color.lstrip('#')))
|
|
698
|
+
except: return False
|
|
699
|
+
|
|
700
|
+
@staticmethod
|
|
701
|
+
def str_to_rgb(rgb_str:str) -> rgb|None:
|
|
702
|
+
try: _rgba = re.match(Regex.rgb_str(allow_alpha=True), rgb_str).groups()
|
|
703
|
+
except: return None
|
|
704
|
+
if _rgba[3] in ['', None]: return rgb(int(_rgba[0]), int(_rgba[1]), int(_rgba[2]))
|
|
705
|
+
else: return rgb(int(_rgba[0]), int(_rgba[1]), int(_rgba[2]), (int(_rgba[3]) if _rgba[3].count('.') == 0 else float(_rgba[3])))
|
|
706
|
+
|
|
707
|
+
@staticmethod
|
|
708
|
+
def to_hex(color:rgb|hsl) -> hexa:
|
|
709
|
+
if isinstance(color, rgb) or Color.is_valid_rgb(color): return rgb(color[0], color[1], color[2], color[3]).to_hex() if Color.has_alpha(color) else rgb(color[0], color[1], color[2]).to_hex()
|
|
710
|
+
if isinstance(color, hsl) or Color.is_valid_hsl(color): return hsl(color[0], color[1], color[2], color[3]).to_hex() if Color.has_alpha(color) else hsl(color[0], color[1], color[2]).to_hex()
|
|
711
|
+
raise ValueError('Invalid color format')
|
|
712
|
+
|
|
713
|
+
@staticmethod
|
|
714
|
+
def to_rgb(color:hsl|hexa) -> rgb:
|
|
715
|
+
if isinstance(color, hsl) or Color.is_valid_hsl(color): return hsl(color[0], color[1], color[2], color[3]).to_rgb() if Color.has_alpha(color) else hsl(color[0], color[1], color[2]).to_rgb()
|
|
716
|
+
if isinstance(color, hexa) or Color.is_valid_hex(color): return hexa(color).to_rgb()
|
|
717
|
+
raise ValueError('Invalid color format')
|
|
718
|
+
|
|
719
|
+
@staticmethod
|
|
720
|
+
def to_hsl(color:rgb|hexa) -> hsl:
|
|
721
|
+
if isinstance(color, hexa) or Color.is_valid_hex(color): color = Color.to_rgb(color)
|
|
722
|
+
elif not Color.is_valid_rgb(color): raise ValueError('Invalid color format')
|
|
723
|
+
r, g, b, a = color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a if Color.has_alpha(color) else None
|
|
724
|
+
max_c, min_c = max(r, g, b), min(r, g, b)
|
|
725
|
+
l = (max_c + min_c) / 2
|
|
726
|
+
if max_c == min_c: h = s = 0
|
|
727
|
+
else:
|
|
728
|
+
delta = max_c - min_c
|
|
729
|
+
s = delta / (1 - abs(2 * l - 1))
|
|
730
|
+
if max_c == r: h = ((g - b) / delta) % 6
|
|
731
|
+
elif max_c == g: h = ((b - r) / delta) + 2
|
|
732
|
+
else: h = ((r - g) / delta) + 4
|
|
733
|
+
h /= 6
|
|
734
|
+
return hsl(int(h * 360), int(s * 100), int(l * 100), a)
|
|
735
|
+
|
|
736
|
+
@staticmethod
|
|
737
|
+
def text_color_for_on_bg(title_bg_color:hexa|rgb = '#FFF') -> hexa|rgb:
|
|
738
|
+
was_hex = False
|
|
739
|
+
if Color.is_valid_hex(title_bg_color): was_hex, title_bg_color = True, Color.to_rgb(title_bg_color)
|
|
740
|
+
brightness = 0.2126 * title_bg_color[0] + 0.7152 * title_bg_color[1] + 0.0722 * title_bg_color[2]
|
|
741
|
+
return (hexa('#FFF') if was_hex else rgb(255, 255, 255)) if brightness < 128 else (hexa('#000') if was_hex else rgb(0, 0, 0))
|
|
742
|
+
|
|
743
|
+
@staticmethod
|
|
744
|
+
def adjust_brightness(color:hexa|rgb, brightness_change:float) -> hexa|rgb:
|
|
745
|
+
"""In- or decrease the brightness of the input color.\n
|
|
746
|
+
-----------------------------------------------------------------------------------------------------------------
|
|
747
|
+
**color** (hexa | rgb): HEX or RGB color.<br>
|
|
748
|
+
**brightness_change** (float): A float between -1.0 (darken by `100%`) and 1.0 (lighten by `100%`), inclusive.\n
|
|
749
|
+
-----------------------------------------------------------------------------------------------------------------
|
|
750
|
+
**returns** (hexa | rgb): The adjusted color in the format of the input color."""
|
|
751
|
+
if Color.is_valid_hex(color, allow_alpha=True): _rgba = Color.to_rgb(color)
|
|
752
|
+
elif Color.is_valid_rgb(color, allow_alpha=True): _rgba = color
|
|
753
|
+
else: raise ValueError(f"Invalid color format '{str(color)}' Use HEX (e.g. '#F00' and '#FF0000') or RGB (e.g. (255, 0, 0) and (255, 0, 0, 1.0))")
|
|
754
|
+
r, g, b, a = _rgba.r, _rgba.g, _rgba.b, _rgba.a if hasattr(_rgba, 'a') else None
|
|
755
|
+
r = int(max(min(r + (255 - r) * brightness_change if brightness_change > 0 else r * (1 + brightness_change), 255), 0))
|
|
756
|
+
g = int(max(min(g + (255 - g) * brightness_change if brightness_change > 0 else g * (1 + brightness_change), 255), 0))
|
|
757
|
+
b = int(max(min(b + (255 - b) * brightness_change if brightness_change > 0 else b * (1 + brightness_change), 255), 0))
|
|
758
|
+
if isinstance(color, (str, hexa)): return Color.to_hex((r, g, b, a))
|
|
759
|
+
else: return rgb(r, g, b, a) if a else rgb(r, g, b)
|
|
760
|
+
|
|
761
|
+
@staticmethod
|
|
762
|
+
def adjust_saturation(color:hexa|rgb, saturation_change:float) -> hexa|rgb:
|
|
763
|
+
if Color.is_valid_hex(color, allow_alpha=True): _rgb = Color.to_rgb(color)
|
|
764
|
+
elif Color.is_valid_rgb(color, allow_alpha=True): _rgb = color
|
|
765
|
+
else: raise ValueError(f'Invalid color format "{str(color)}". Use HEX (e.g. "#F00" and "#FF0000") or RGB (e.g. (255, 0, 0) and (255, 0, 0, 1.0))')
|
|
766
|
+
hsl = Color.to_hsl(_rgb)
|
|
767
|
+
h, s, l, a = hsl[0], hsl[1], hsl[2], hsl[3]
|
|
768
|
+
s = max(0, min(100, s + saturation_change * 100))
|
|
769
|
+
if isinstance(color, (str, hexa)): return Color.to_hex(Color.to_rgb((h, s, l, a)))
|
|
770
|
+
return Color.to_rgb((h, s, l, a))
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
################################################## DATA OPERATIONS ##################################################
|
|
774
|
+
|
|
775
|
+
class Data:
|
|
776
|
+
@staticmethod
|
|
777
|
+
def chars_count(data:list|tuple|set|frozenset|dict) -> int:
|
|
778
|
+
"""The sum of all the characters including the keys in dictionaries."""
|
|
779
|
+
if isinstance(data, dict): return sum(len(str(k)) + len(str(v)) for k, v in data.items())
|
|
780
|
+
return sum(len(str(item)) for item in data)
|
|
781
|
+
|
|
782
|
+
@staticmethod
|
|
783
|
+
def strip(data:list|tuple|dict) -> list|tuple|dict:
|
|
784
|
+
if isinstance(data, dict): return {k: v.strip() if isinstance(v, str) else Data.strip(v) for k, v in data.items()}
|
|
785
|
+
if isinstance(data, (list, tuple)):
|
|
786
|
+
stripped = [item.strip() if isinstance(item, str) else Data.strip(item) for item in data]
|
|
787
|
+
return tuple(stripped) if isinstance(data, tuple) else stripped
|
|
788
|
+
return data.strip() if isinstance(data, str) else data
|
|
789
|
+
|
|
790
|
+
@staticmethod
|
|
791
|
+
def remove(data:list|tuple|dict, items:list[str]) -> list|tuple|dict:
|
|
792
|
+
"""Remove multiple items from lists and tuples or keys from dictionaries."""
|
|
793
|
+
if isinstance(data, (list, tuple)):
|
|
794
|
+
result = [k for k in data if k not in items]
|
|
795
|
+
return result if isinstance(data, list) else tuple(result)
|
|
796
|
+
if isinstance(data, dict): return {k: v for k, v in data.items() if k not in items}
|
|
797
|
+
|
|
798
|
+
@staticmethod
|
|
799
|
+
def remove_empty_items(data:list|tuple|dict, spaces_are_empty:bool = False) -> list|tuple|dict:
|
|
800
|
+
if isinstance(data, dict):
|
|
801
|
+
filtered_dict = {}
|
|
802
|
+
for key, value in data.items():
|
|
803
|
+
if isinstance(value, (list, tuple, dict)):
|
|
804
|
+
filtered_value = Data.remove_empty_items(value, spaces_are_empty)
|
|
805
|
+
if filtered_value: filtered_dict[key] = filtered_value
|
|
806
|
+
elif value not in ['', None] and not ((spaces_are_empty and isinstance(value, str)) and value.strip() in ['', None]): filtered_dict[key] = value
|
|
807
|
+
return filtered_dict
|
|
808
|
+
filtered = []
|
|
809
|
+
for item in data:
|
|
810
|
+
if isinstance(item, (list, tuple, dict)):
|
|
811
|
+
deduped_item = Data.remove_empty_items(item, spaces_are_empty)
|
|
812
|
+
if deduped_item:
|
|
813
|
+
if isinstance(item, tuple): deduped_item = tuple(deduped_item)
|
|
814
|
+
filtered.append(deduped_item)
|
|
815
|
+
elif item not in ['', None] and not ((spaces_are_empty and isinstance(item, str)) and item.strip() in ['', None]): filtered.append(item)
|
|
816
|
+
return tuple(filtered) if isinstance(data, tuple) else filtered
|
|
817
|
+
|
|
818
|
+
@staticmethod
|
|
819
|
+
def remove_duplicates(data:list|tuple|dict) -> list|tuple|dict:
|
|
820
|
+
if isinstance(data, dict): return {k: Data.remove_duplicates(v) for k, v in data.items()}
|
|
821
|
+
if isinstance(data, (list, tuple)):
|
|
822
|
+
unique_items = []
|
|
823
|
+
for item in data:
|
|
824
|
+
if isinstance(item, (list, tuple, set, dict)):
|
|
825
|
+
deduped_item = Data.remove_duplicates(item)
|
|
826
|
+
if deduped_item not in unique_items: unique_items.append(deduped_item)
|
|
827
|
+
elif item not in unique_items: unique_items.append(item)
|
|
828
|
+
return tuple(unique_items) if isinstance(data, tuple) else unique_items
|
|
829
|
+
return data
|
|
830
|
+
|
|
831
|
+
@staticmethod
|
|
832
|
+
def remove_comments(data:list|tuple|dict, comment_start:str = '>>', comment_end:str = '<<', comment_sep:str = '') -> list|tuple|dict:
|
|
833
|
+
"""Remove comments from a list, tuple or dictionary.\n
|
|
834
|
+
-----------------------------------------------------------------------------------------------------------------
|
|
835
|
+
The `data` parameter is your list, tuple or dictionary, where the comments should get removed from.<br>
|
|
836
|
+
The `comment_start` parameter is the string that marks the start of a comment inside `data`. (default: `>>`)<br>
|
|
837
|
+
The `comment_end` parameter is the string that marks the end of a comment inside `data`. (default: `<<`)<br>
|
|
838
|
+
The `comment_sep` parameter is a string with which a comment will be replaced, if it is between strings.\n
|
|
839
|
+
-----------------------------------------------------------------------------------------------------------------
|
|
840
|
+
Examples:\n
|
|
841
|
+
```python\n data = {
|
|
842
|
+
'key1': [
|
|
843
|
+
'>> COMMENT IN THE BEGINNING OF THE STRING << value1',
|
|
844
|
+
'value2 >> COMMENT IN THE END OF THE STRING',
|
|
845
|
+
'val>> COMMENT IN THE MIDDLE OF THE STRING <<ue3',
|
|
846
|
+
'>> FULL VALUE IS A COMMENT value4'
|
|
847
|
+
],
|
|
848
|
+
'>> FULL KEY + ALL ITS VALUES ARE A COMMENT key2': [
|
|
849
|
+
'value',
|
|
850
|
+
'value',
|
|
851
|
+
'value'
|
|
852
|
+
],
|
|
853
|
+
'key3': '>> ALL THE KEYS VALUES ARE COMMENTS value'
|
|
854
|
+
}
|
|
855
|
+
processed_data = Data.remove_comments(data, comment_start='>>', comment_end='<<', comment_sep='__')\n```
|
|
856
|
+
-----------------------------------------------------------------------------------------------------------------
|
|
857
|
+
For this example, `processed_data` will be:
|
|
858
|
+
```python\n {
|
|
859
|
+
'key1': [
|
|
860
|
+
'value1',
|
|
861
|
+
'value2',
|
|
862
|
+
'val__ue3'
|
|
863
|
+
],
|
|
864
|
+
'key3': None
|
|
865
|
+
}\n```
|
|
866
|
+
For `key1`, all the comments will just be removed, except at `value3` and `value4`:<br>
|
|
867
|
+
`value3` The comment is removed and the parts left and right are joined through `comment_sep`.<br>
|
|
868
|
+
`value4` The whole value is removed, since the whole value was a comment.<br>
|
|
869
|
+
For `key2`, the key, including its whole values will be removed.<br>
|
|
870
|
+
For `key3`, since all its values are just comments, the key will still exist, but with a value of `None`."""
|
|
871
|
+
def process_item(item:dict|list|tuple|str) -> dict|list|tuple|str|None:
|
|
872
|
+
if isinstance(item, dict):
|
|
873
|
+
processed_dict = {}
|
|
874
|
+
for key, val in item.items():
|
|
875
|
+
processed_key = process_item(key)
|
|
876
|
+
if processed_key is not None:
|
|
877
|
+
processed_val = process_item(val)
|
|
878
|
+
if isinstance(val, (list, tuple, dict)):
|
|
879
|
+
if processed_val: processed_dict[processed_key] = processed_val
|
|
880
|
+
elif processed_val is not None: processed_dict[processed_key] = processed_val
|
|
881
|
+
else: processed_dict[processed_key] = None
|
|
882
|
+
return processed_dict
|
|
883
|
+
elif isinstance(item, list): return [v for v in (process_item(val) for val in item) if v is not None]
|
|
884
|
+
elif isinstance(item, tuple): return tuple(v for v in (process_item(val) for val in item) if v is not None)
|
|
885
|
+
elif isinstance(item, str):
|
|
886
|
+
if comment_end: no_comments = re.sub(rf'^((?:(?!{re.escape(comment_start)}).)*){re.escape(comment_start)}(?:(?:(?!{re.escape(comment_end)}).)*)(?:{re.escape(comment_end)})?(.*?)$',
|
|
887
|
+
lambda m: f'{m.group(1).strip()}{comment_sep if (m.group(1).strip() not in ['', None]) and (m.group(2).strip() not in ['', None]) else ''}{m.group(2).strip()}', item)
|
|
888
|
+
else: no_comments = None if item.lstrip().startswith(comment_start) else item
|
|
889
|
+
return no_comments.strip() if no_comments and no_comments.strip() != '' else None
|
|
890
|
+
else: return item
|
|
891
|
+
return process_item(data)
|
|
892
|
+
|
|
893
|
+
@staticmethod
|
|
894
|
+
def is_equal(data1:list|tuple|dict, data2:list|tuple|dict, ignore_paths:str|list[str] = '', comment_start:str = '>>', comment_end:str = '<<', sep:str = '->') -> bool:
|
|
895
|
+
"""Compares two structures and returns `True` if they are equal and `False` otherwise.\n
|
|
896
|
+
⇾ **Will not detect, if a key-name has changed, only if removed or added.**\n
|
|
897
|
+
------------------------------------------------------------------------------------------------
|
|
898
|
+
Ignores the specified (found) key/s or item/s from `ignore_paths`. Comments are not ignored<br>
|
|
899
|
+
when comparing. `comment_start` and `comment_end` are only used for key recognition.\n
|
|
900
|
+
------------------------------------------------------------------------------------------------
|
|
901
|
+
The paths from `ignore_paths` work exactly the same way as the paths from `value_paths`<br>
|
|
902
|
+
in the function `Data.get_path_id()`, just like the `sep` parameter. For more detailed<br>
|
|
903
|
+
explanation, see the documentation of the function `Data.get_path_id()`."""
|
|
904
|
+
def process_ignore_paths(ignore_paths:str|list[str]) -> list[list[str]]:
|
|
905
|
+
if isinstance(ignore_paths, str): ignore_paths = [ignore_paths]
|
|
906
|
+
return [path.split(sep) for path in ignore_paths if path]
|
|
907
|
+
def compare(d1:dict|list|tuple, d2:dict|list|tuple, ignore_paths:list[list[str]], current_path:list = []) -> bool:
|
|
908
|
+
if ignore_paths and any(current_path == path[:len(current_path)] and len(current_path) == len(path) for path in ignore_paths): return True
|
|
909
|
+
if isinstance(d1, dict) and isinstance(d2, dict):
|
|
910
|
+
if set(d1.keys()) != set(d2.keys()): return False
|
|
911
|
+
return all(compare(d1[key], d2[key], ignore_paths, current_path + [key]) for key in d1)
|
|
912
|
+
elif isinstance(d1, (list, tuple)) and isinstance(d2, (list, tuple)):
|
|
913
|
+
if len(d1) != len(d2): return False
|
|
914
|
+
return all(compare(item1, item2, ignore_paths, current_path + [str(i)]) for i, (item1, item2) in enumerate(zip(d1, d2)))
|
|
915
|
+
else: return d1 == d2
|
|
916
|
+
return compare(Data.remove_comments(data1, comment_start, comment_end), Data.remove_comments(data2, comment_start, comment_end), process_ignore_paths(ignore_paths))
|
|
917
|
+
|
|
918
|
+
@staticmethod
|
|
919
|
+
def get_fingerprint(data:list|tuple|dict) -> list|tuple|dict|None:
|
|
920
|
+
if isinstance(data, dict): return {i: type(v).__name__ for i, v in enumerate(data.values())}
|
|
921
|
+
elif isinstance(data, (list, tuple)): return {i: type(v).__name__ for i, v in enumerate(data)}
|
|
922
|
+
return None
|
|
923
|
+
|
|
924
|
+
@staticmethod
|
|
925
|
+
def get_path_id(data:list|tuple|dict, value_paths:str|list[str], sep:str = '->', ignore_not_found:bool = False) -> str|list[str]:
|
|
926
|
+
"""Generates a unique ID based on the path to a specific value within a nested data structure.\n
|
|
927
|
+
-------------------------------------------------------------------------------------------------
|
|
928
|
+
The `data` parameter is the list, tuple, or dictionary, which the id should be generated for.\n
|
|
929
|
+
-------------------------------------------------------------------------------------------------
|
|
930
|
+
The param `value_path` is a sort of path (or a list of paths) to the value/s to be updated.<br>
|
|
931
|
+
In this example:
|
|
932
|
+
```\n {
|
|
933
|
+
'healthy': {
|
|
934
|
+
'fruit': ['apples', 'bananas', 'oranges'],
|
|
935
|
+
'vegetables': ['carrots', 'broccoli', 'celery']
|
|
936
|
+
}
|
|
937
|
+
}\n```
|
|
938
|
+
... if you want to change the value of `'apples'` to `'strawberries'`, `value_path`<br>
|
|
939
|
+
would be `healthy->fruit->apples` or if you don't know that the value is `apples`<br>
|
|
940
|
+
you can also use the position of the value, so `healthy->fruit->0`.\n
|
|
941
|
+
-------------------------------------------------------------------------------------------------
|
|
942
|
+
The `sep` param is the separator between the keys in the path<br>
|
|
943
|
+
(default is `->` just like in the example above).\n
|
|
944
|
+
-------------------------------------------------------------------------------------------------
|
|
945
|
+
If `ignore_not_found` is `True`, the function will return `None` if the value is not<br>
|
|
946
|
+
found instead of raising an error."""
|
|
947
|
+
if isinstance(value_paths, str): value_paths = [value_paths]
|
|
948
|
+
path_ids = []
|
|
949
|
+
for path in value_paths:
|
|
950
|
+
keys = [k.strip() for k in path.split(str(sep).strip()) if k.strip() != '']
|
|
951
|
+
id_part_len, _path_ids, _obj = 0, [], data
|
|
952
|
+
try:
|
|
953
|
+
for k in keys:
|
|
954
|
+
if isinstance(_obj, dict):
|
|
955
|
+
if k.isdigit(): raise TypeError(f'Key \'{k}\' is invalid for a dict type.')
|
|
956
|
+
try:
|
|
957
|
+
idx = list(_obj.keys()).index(k)
|
|
958
|
+
_path_ids.append(idx)
|
|
959
|
+
_obj = _obj[k]
|
|
960
|
+
except KeyError:
|
|
961
|
+
if ignore_not_found:
|
|
962
|
+
_path_ids = None
|
|
963
|
+
break
|
|
964
|
+
raise KeyError(f'Key \'{k}\' not found in dict.')
|
|
965
|
+
elif isinstance(_obj, (list, tuple)):
|
|
966
|
+
try:
|
|
967
|
+
idx = int(k)
|
|
968
|
+
_path_ids.append(idx)
|
|
969
|
+
_obj = _obj[idx]
|
|
970
|
+
except ValueError:
|
|
971
|
+
try:
|
|
972
|
+
idx = _obj.index(k)
|
|
973
|
+
_path_ids.append(idx)
|
|
974
|
+
_obj = _obj[idx]
|
|
975
|
+
except ValueError:
|
|
976
|
+
if ignore_not_found:
|
|
977
|
+
_path_ids = None
|
|
978
|
+
break
|
|
979
|
+
raise ValueError(f'Value \'{k}\' not found in list/tuple.')
|
|
980
|
+
else: break
|
|
981
|
+
if _path_ids: id_part_len = max(id_part_len, len(str(_path_ids[-1])))
|
|
982
|
+
if _path_ids is not None: path_ids.append(f'{id_part_len}>{''.join([str(id).zfill(id_part_len) for id in _path_ids])}')
|
|
983
|
+
elif ignore_not_found: path_ids.append(None)
|
|
984
|
+
except (KeyError, ValueError, TypeError) as e:
|
|
985
|
+
if ignore_not_found: path_ids.append(None)
|
|
986
|
+
else: raise e
|
|
987
|
+
return path_ids if len(path_ids) > 1 else path_ids[0] if len(path_ids) == 1 else None
|
|
988
|
+
|
|
989
|
+
@staticmethod
|
|
990
|
+
def get_value_by_path_id(data:list|tuple|dict, path_id:str, get_key:bool = False) -> any:
|
|
991
|
+
"""Retrieves the value from `data` using the provided `path_id`.\n
|
|
992
|
+
------------------------------------------------------------------------------------
|
|
993
|
+
Input a list, tuple or dict as `data`, along with `path_id`, which is a path-id<br>
|
|
994
|
+
that was created before using `Object.get_path_id()`. If `get_key` is True<br>
|
|
995
|
+
and the final item is in a dict, it returns the key instead of the value.\n
|
|
996
|
+
------------------------------------------------------------------------------------
|
|
997
|
+
The function will return the value (or key) from the path-id location, as long as<br>
|
|
998
|
+
the structure of `data` hasn't changed since creating the path-id to that value."""
|
|
999
|
+
def get_nested(data:list|tuple|dict, path:list[int], get_key:bool) -> any:
|
|
1000
|
+
parent = None
|
|
1001
|
+
for i, idx in enumerate(path):
|
|
1002
|
+
if isinstance(data, dict):
|
|
1003
|
+
keys = list(data.keys())
|
|
1004
|
+
if i == len(path) - 1 and get_key: return keys[idx]
|
|
1005
|
+
parent = data
|
|
1006
|
+
data = data[keys[idx]]
|
|
1007
|
+
elif isinstance(data, (list, tuple)):
|
|
1008
|
+
if i == len(path) - 1 and get_key:
|
|
1009
|
+
if parent is None or not isinstance(parent, dict): raise ValueError('Cannot get key from list or tuple without a parent dictionary')
|
|
1010
|
+
return next(key for key, value in parent.items() if value is data)
|
|
1011
|
+
parent = data
|
|
1012
|
+
data = data[idx]
|
|
1013
|
+
else: raise TypeError(f'Unsupported type {type(data)} at path {path[:i+1]}')
|
|
1014
|
+
return data
|
|
1015
|
+
path = Data._sep_path_id(path_id)
|
|
1016
|
+
return get_nested(data, path, get_key)
|
|
1017
|
+
|
|
1018
|
+
@staticmethod
|
|
1019
|
+
def set_value_by_path_id(data:list|tuple|dict, update_values:str|list[str], sep:str = '::') -> list|tuple|dict:
|
|
1020
|
+
"""Updates the value/s from `update_values` in the `data`.\n
|
|
1021
|
+
--------------------------------------------------------------------------------
|
|
1022
|
+
Input a list, tuple or dict as `data`, along with `update_values`, which is<br>
|
|
1023
|
+
a path-id that was created before using `Object.get_path_id()`, together<br>
|
|
1024
|
+
with the new value to be inserted where the path-id points to. The path-id<br>
|
|
1025
|
+
and the new value are separated by `sep`, which per default is `::`.\n
|
|
1026
|
+
--------------------------------------------------------------------------------
|
|
1027
|
+
The value from path-id will be changed to the new value, as long as the<br>
|
|
1028
|
+
structure of `data` hasn't changed since creating the path-id to that value."""
|
|
1029
|
+
def update_nested(data:list|tuple|dict, path:list[int], value:any) -> list|tuple|dict:
|
|
1030
|
+
if len(path) == 1:
|
|
1031
|
+
if isinstance(data, dict):
|
|
1032
|
+
keys = list(data.keys())
|
|
1033
|
+
data[keys[path[0]]] = value
|
|
1034
|
+
elif isinstance(data, (list, tuple)):
|
|
1035
|
+
data = list(data)
|
|
1036
|
+
data[path[0]] = value
|
|
1037
|
+
data = type(data)(data)
|
|
1038
|
+
elif isinstance(data, dict):
|
|
1039
|
+
keys = list(data.keys())
|
|
1040
|
+
key = keys[path[0]]
|
|
1041
|
+
data[key] = update_nested(data[key], path[1:], value)
|
|
1042
|
+
elif isinstance(data, (list, tuple)):
|
|
1043
|
+
data = list(data)
|
|
1044
|
+
data[path[0]] = update_nested(data[path[0]], path[1:], value)
|
|
1045
|
+
data = type(data)(data)
|
|
1046
|
+
return data
|
|
1047
|
+
if isinstance(update_values, str): update_values = [update_values]
|
|
1048
|
+
valid_entries = [(parts[0].strip(), parts[1]) for update_value in update_values
|
|
1049
|
+
if len(parts := update_value.split(str(sep).strip())) == 2]
|
|
1050
|
+
if not valid_entries: raise ValueError(f'No valid update_values found: {update_values}')
|
|
1051
|
+
path, new_values = (zip(*valid_entries) if valid_entries else ([], []))
|
|
1052
|
+
for path_id, new_val in zip(path, new_values):
|
|
1053
|
+
path = Data._sep_path_id(path_id)
|
|
1054
|
+
data = update_nested(data, path, new_val)
|
|
1055
|
+
return data
|
|
1056
|
+
|
|
1057
|
+
@staticmethod
|
|
1058
|
+
def print(data:list|tuple|dict, indent:int = 2, compactness:int = 0, max_width:int = 140, as_json:bool = False, end:str = '\n') -> None:
|
|
1059
|
+
"""Print nicely formatted data structures.\n
|
|
1060
|
+
------------------------------------------------------------------------------------
|
|
1061
|
+
The indentation spaces-amount can be set with with `indent`.<br>
|
|
1062
|
+
There are three different levels of `compactness`:<br>
|
|
1063
|
+
`0` expands everything possible<br>
|
|
1064
|
+
`1` only expands if there's other lists, tuples or dicts inside of data or,<br>
|
|
1065
|
+
⠀if the data's content is longer than `max_width`<br>
|
|
1066
|
+
`2` keeps everything collapsed (all on one line)\n
|
|
1067
|
+
------------------------------------------------------------------------------------
|
|
1068
|
+
If `as_json` is set to `True`, the output will be in valid JSON format."""
|
|
1069
|
+
print(Data.to_str(data, indent, compactness, max_width, as_json), end=end, flush=True)
|
|
1070
|
+
|
|
1071
|
+
@staticmethod
|
|
1072
|
+
def to_str(data:list|tuple|dict, indent:int = 2, compactness:int = 0, max_width:int = 140, as_json:bool = False) -> str:
|
|
1073
|
+
"""Get nicely formatted data structure-strings.\n
|
|
1074
|
+
------------------------------------------------------------------------------------
|
|
1075
|
+
The indentation spaces-amount can be set with with `indent`.<br>
|
|
1076
|
+
There are three different levels of `compactness`:<br>
|
|
1077
|
+
`0` expands everything possible<br>
|
|
1078
|
+
`1` only expands if there's other lists, tuples or dicts inside of data or,<br>
|
|
1079
|
+
⠀if the data's content is longer than `max_width`<br>
|
|
1080
|
+
`2` keeps everything collapsed (all on one line)\n
|
|
1081
|
+
------------------------------------------------------------------------------------
|
|
1082
|
+
If `as_json` is set to `True`, the output will be in valid JSON format."""
|
|
1083
|
+
def escape_string(s:str, str_quotes:str = '"') -> str:
|
|
1084
|
+
s = s.replace('\\', r'\\').replace('\n', r'\n').replace('\r', r'\r').replace('\t', r'\t').replace('\b', r'\b').replace('\f', r'\f').replace('\a', r'\a')
|
|
1085
|
+
if str_quotes == '"': s = s.replace(r"\\'", "'").replace(r'"', r'\"')
|
|
1086
|
+
if str_quotes == "'": s = s.replace(r'\\"', '"').replace(r"'", r"\'")
|
|
1087
|
+
return s
|
|
1088
|
+
def format_value(value:any, current_indent:int) -> str:
|
|
1089
|
+
if isinstance(value, dict): return format_dict(value, current_indent + indent)
|
|
1090
|
+
elif hasattr(value, '__dict__'): return format_dict(value.__dict__, current_indent + indent)
|
|
1091
|
+
elif isinstance(value, (list, tuple, set, frozenset)): return format_sequence(value, current_indent + indent)
|
|
1092
|
+
elif isinstance(value, bool): return str(value).lower() if as_json else str(value)
|
|
1093
|
+
elif isinstance(value, (int, float)): return 'null' if as_json and (math.isinf(value) or math.isnan(value)) else str(value)
|
|
1094
|
+
elif isinstance(value, complex): return f'[{value.real}, {value.imag}]' if as_json else str(value)
|
|
1095
|
+
elif value is None: return 'null' if as_json else 'None'
|
|
1096
|
+
else: return f'"{escape_string(str(value), '"')}"' if as_json else f"'{escape_string(str(value), "'")}'"
|
|
1097
|
+
def should_expand(seq:list|tuple|dict) -> bool:
|
|
1098
|
+
if compactness == 0: return True
|
|
1099
|
+
if compactness == 2: return False
|
|
1100
|
+
complex_items = sum(1 for item in seq if isinstance(item, (list, tuple, dict, set, frozenset)))
|
|
1101
|
+
return complex_items > 1 or (complex_items == 1 and len(seq) > 1) or Data.chars_count(seq) > max_width
|
|
1102
|
+
def format_key(k:any) -> str: return f'"{escape_string(str(k), '"')}"' if as_json else f"'{escape_string(str(k), "'")}'" if isinstance(k, str) else str(k)
|
|
1103
|
+
def format_dict(d:dict, current_indent:int) -> str:
|
|
1104
|
+
if not d or compactness == 2: return '{' + ', '.join(f'{format_key(k)}: {format_value(v, current_indent)}' for k, v in d.items()) + '}'
|
|
1105
|
+
if not should_expand(d.values()): return '{' + ', '.join(f'{format_key(k)}: {format_value(v, current_indent)}' for k, v in d.items()) + '}'
|
|
1106
|
+
items = []
|
|
1107
|
+
for key, value in d.items():
|
|
1108
|
+
formatted_value = format_value(value, current_indent)
|
|
1109
|
+
items.append(f'{' ' * (current_indent + indent)}{format_key(key)}: {formatted_value}')
|
|
1110
|
+
return '{\n' + ',\n'.join(items) + f'\n{' ' * current_indent}}}'
|
|
1111
|
+
def format_sequence(seq, current_indent:int) -> str:
|
|
1112
|
+
if as_json: seq = list(seq)
|
|
1113
|
+
if not seq or compactness == 2: return '[' + ', '.join(format_value(item, current_indent) for item in seq) + ']' if isinstance(seq, list) else '(' + ', '.join(format_value(item, current_indent) for item in seq) + ')'
|
|
1114
|
+
if not should_expand(seq): return '[' + ', '.join(format_value(item, current_indent) for item in seq) + ']' if isinstance(seq, list) else '(' + ', '.join(format_value(item, current_indent) for item in seq) + ')'
|
|
1115
|
+
items = [format_value(item, current_indent) for item in seq]
|
|
1116
|
+
formatted_items = ',\n'.join(f'{' ' * (current_indent + indent)}{item}' for item in items)
|
|
1117
|
+
if isinstance(seq, list): return '[\n' + formatted_items + f'\n{' ' * current_indent}]'
|
|
1118
|
+
else: return '(\n' + formatted_items + f'\n{' ' * current_indent})'
|
|
1119
|
+
return format_dict(data, 0) if isinstance(data, dict) else format_sequence(data, 0)
|
|
1120
|
+
|
|
1121
|
+
@staticmethod
|
|
1122
|
+
def _is_key(data:list|tuple|dict, path_id:str) -> bool:
|
|
1123
|
+
"""Returns `True` if the path-id points to a key in `data` and `False` otherwise.\n
|
|
1124
|
+
------------------------------------------------------------------------------------
|
|
1125
|
+
Input a list, tuple or dict as `data`, along with `path_id`, which is a path-id<br>
|
|
1126
|
+
that was created before using `Object.get_path_id()`."""
|
|
1127
|
+
def check_nested(data:list|tuple|dict, path:list[int]) -> bool:
|
|
1128
|
+
for i, idx in enumerate(path):
|
|
1129
|
+
if isinstance(data, dict):
|
|
1130
|
+
keys = list(data.keys())
|
|
1131
|
+
if i == len(path) - 1:
|
|
1132
|
+
return True
|
|
1133
|
+
data = data[keys[idx]]
|
|
1134
|
+
elif isinstance(data, (list, tuple)):
|
|
1135
|
+
return False
|
|
1136
|
+
else:
|
|
1137
|
+
raise TypeError(f'Unsupported type {type(data)} at path {path[:i+1]}')
|
|
1138
|
+
return False
|
|
1139
|
+
if isinstance(data, (list, tuple)):
|
|
1140
|
+
return False
|
|
1141
|
+
path = Data._sep_path_id(path_id)
|
|
1142
|
+
return check_nested(data, path)
|
|
1143
|
+
|
|
1144
|
+
@staticmethod
|
|
1145
|
+
def _sep_path_id(path_id:str) -> list[int]:
|
|
1146
|
+
if path_id.count('>') != 1: raise ValueError(f'Invalid path-id: {path_id}')
|
|
1147
|
+
id_part_len, path_ids_str = int(path_id.split('>')[0]), path_id.split('>')[1]
|
|
1148
|
+
return [int(path_ids_str[i:i+id_part_len]) for i in range(0, len(path_ids_str), id_part_len)]
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
################################################## STRING OPERATIONS ##################################################
|
|
1153
|
+
|
|
1154
|
+
class String:
|
|
1155
|
+
@staticmethod
|
|
1156
|
+
def to_type(value: str):
|
|
1157
|
+
if value.lower() in ['true', 'false']: return value.lower() == 'true' # BOOLEAN
|
|
1158
|
+
if value.lower() in ['none', 'null', 'undefined']: return None # NONE
|
|
1159
|
+
if value.startswith('[') and value.endswith(']'): return [String.to_type(item.strip()) for item in value[1:-1].split(',') if item.strip()] # LIST
|
|
1160
|
+
if value.startswith('(') and value.endswith(')'): return tuple(String.to_type(item.strip()) for item in value[1:-1].split(',') if item.strip()) # TUPLE
|
|
1161
|
+
if value.startswith('{') and value.endswith('}'): return {String.to_type(item.strip()) for item in value[1:-1].split(',') if item.strip()} # SET
|
|
1162
|
+
if value.startswith('{') and value.endswith('}') and ':' in value: return {String.to_type(k.strip()): String.to_type(v.strip()) for k, v in [item.split(':') for item in value[1:-1].split(',') if item.strip()]} # DICT
|
|
1163
|
+
try: # NUMBER (INT OR FLOAT)
|
|
1164
|
+
if '.' in value or 'e' in value.lower(): return float(value)
|
|
1165
|
+
else: return int(value)
|
|
1166
|
+
except ValueError: pass
|
|
1167
|
+
if value.startswith(("'", '"')) and value.endswith(("'", '"')): return value[1:-1] # STRING (WITH OR WITHOUT QUOTES)
|
|
1168
|
+
try: return complex(value) # COMPLEX
|
|
1169
|
+
except ValueError: pass
|
|
1170
|
+
return value # IF NOTHING ELSE MATCHES, RETURN AS IS
|
|
1171
|
+
|
|
1172
|
+
@staticmethod
|
|
1173
|
+
def get_repeated_symbol(string:str, symbol:str) -> int|bool:
|
|
1174
|
+
if len(string) == len(symbol) * string.count(symbol): return string.count(symbol)
|
|
1175
|
+
else: return False
|
|
1176
|
+
|
|
1177
|
+
@staticmethod
|
|
1178
|
+
def decompose(case_string:str, seps:str = '-_') -> list:
|
|
1179
|
+
return [part.lower() for part in re.split(rf'(?<=[a-z])(?=[A-Z])|[{seps}]', case_string)]
|
|
1180
|
+
|
|
1181
|
+
@staticmethod
|
|
1182
|
+
def to_camel_case(string:str) -> str:
|
|
1183
|
+
return ''.join(part.capitalize() for part in String.decompose(string))
|
|
1184
|
+
|
|
1185
|
+
@staticmethod
|
|
1186
|
+
def to_snake_case(string:str, sep:str = '_', screaming:bool = False) -> str:
|
|
1187
|
+
return sep.join(part.upper() if screaming else part for part in String.decompose(string))
|
|
1188
|
+
|
|
1189
|
+
@staticmethod
|
|
1190
|
+
def get_string_lines(string:str, remove_empty_lines:bool = False) -> list:
|
|
1191
|
+
if not remove_empty_lines: return string.splitlines()
|
|
1192
|
+
lines = string.splitlines()
|
|
1193
|
+
if not lines: return []
|
|
1194
|
+
non_empty_lines = [line for line in lines if line.strip()]
|
|
1195
|
+
if not non_empty_lines: return []
|
|
1196
|
+
return non_empty_lines
|
|
1197
|
+
|
|
1198
|
+
@staticmethod
|
|
1199
|
+
def remove_consecutive_empty_lines(string:str, max_consecutive:int = 0) -> str:
|
|
1200
|
+
return re.sub(r'(\n\s*){2,}', r'\1' * (max_consecutive + 1), string)
|
|
1201
|
+
|
|
1202
|
+
@staticmethod
|
|
1203
|
+
def multi_strip(string:str, strip_chars:str = ' _-') -> str:
|
|
1204
|
+
for char in string:
|
|
1205
|
+
if char in strip_chars: string = string[1:]
|
|
1206
|
+
else: break
|
|
1207
|
+
for char in string[::-1]:
|
|
1208
|
+
if char in strip_chars: string = string[:-1]
|
|
1209
|
+
else: break
|
|
1210
|
+
return string
|
|
1211
|
+
|
|
1212
|
+
@staticmethod
|
|
1213
|
+
def multi_lstrip(string:str, strip_chars:str = ' _-') -> str:
|
|
1214
|
+
for char in string:
|
|
1215
|
+
if char in strip_chars: string = string[1:]
|
|
1216
|
+
else: break
|
|
1217
|
+
return string
|
|
1218
|
+
|
|
1219
|
+
@staticmethod
|
|
1220
|
+
def multi_rstrip(string:str, strip_chars:str = ' _-') -> str:
|
|
1221
|
+
for char in string[::-1]:
|
|
1222
|
+
if char in strip_chars: string = string[:-1]
|
|
1223
|
+
else: break
|
|
1224
|
+
return string
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
################################################## CODE-STRING OPERATIONS ##################################################
|
|
1229
|
+
|
|
1230
|
+
class Code:
|
|
1231
|
+
@staticmethod
|
|
1232
|
+
def add_indent(code:str, indent:int) -> str:
|
|
1233
|
+
indented_lines = [' ' * indent + line for line in code.splitlines()]
|
|
1234
|
+
return '\n'.join(indented_lines)
|
|
1235
|
+
|
|
1236
|
+
@staticmethod
|
|
1237
|
+
def get_tab_spaces(code:str) -> int:
|
|
1238
|
+
code_lines = String.get_string_lines(code, remove_empty_lines=True)
|
|
1239
|
+
indents = [len(line) - len(line.lstrip()) for line in code_lines]
|
|
1240
|
+
non_zero_indents = [i for i in indents if i > 0]
|
|
1241
|
+
return min(non_zero_indents) if non_zero_indents else 0
|
|
1242
|
+
|
|
1243
|
+
@staticmethod
|
|
1244
|
+
def change_tab_size(code:str, new_tab_size:int, remove_empty_lines:bool = False) -> str:
|
|
1245
|
+
code_lines = String.get_string_lines(code, remove_empty_lines=True)
|
|
1246
|
+
lines = code_lines if remove_empty_lines else String.get_string_lines(code)
|
|
1247
|
+
tab_spaces = Code.get_tab_spaces(code)
|
|
1248
|
+
if (tab_spaces == new_tab_size) or tab_spaces == 0:
|
|
1249
|
+
if remove_empty_lines: return '\n'.join(code_lines)
|
|
1250
|
+
return code
|
|
1251
|
+
result = []
|
|
1252
|
+
for line in lines:
|
|
1253
|
+
stripped = line.lstrip()
|
|
1254
|
+
indent_level = (len(line) - len(stripped)) // tab_spaces
|
|
1255
|
+
new_indent = ' ' * (indent_level * new_tab_size)
|
|
1256
|
+
result.append(new_indent + stripped)
|
|
1257
|
+
return '\n'.join(result)
|
|
1258
|
+
|
|
1259
|
+
@staticmethod
|
|
1260
|
+
def get_func_calls(code:str) -> list:
|
|
1261
|
+
funcs, nested_func_calls = rx.findall(r'(?i)' + Regex.func_call(), code), []
|
|
1262
|
+
for _, func_attrs in funcs:
|
|
1263
|
+
nested_calls = rx.findall(r'(?i)' + Regex.func_call(), func_attrs)
|
|
1264
|
+
if nested_calls: nested_func_calls.extend(nested_calls)
|
|
1265
|
+
return Data.remove_duplicates(funcs + nested_func_calls)
|
|
1266
|
+
|
|
1267
|
+
@staticmethod
|
|
1268
|
+
def is_js(code:str, funcs:list = ['__', '$t', '$lang']) -> bool:
|
|
1269
|
+
funcs = '|'.join(funcs)
|
|
1270
|
+
js_pattern = rx.compile(Regex.outside_strings(r'''^(?:
|
|
1271
|
+
(\$[\w_]+)\s* # JQUERY-STYLE VARIABLES
|
|
1272
|
+
|(\$[\w_]+\s*\() # JQUERY-STYLE FUNCTION CALLS
|
|
1273
|
+
|((''' + funcs + r')' + Regex.brackets('()') + r'''\s*) # PREDEFINED FUNCTION CALLS
|
|
1274
|
+
|(\bfunction\s*\() # FUNCTION DECLARATIONS
|
|
1275
|
+
|(\b(var|let|const)\s+[\w_]+\s*=) # VARIABLE DECLARATIONS
|
|
1276
|
+
|(\b(if|for|while|switch)\s*\() # CONTROL STRUCTURES
|
|
1277
|
+
|(\b(return|throw)\s+) # RETURN OR THROW STATEMENTS
|
|
1278
|
+
|(\bnew\s+[\w_]+\() # OBJECT INSTANTIATION
|
|
1279
|
+
|(\b[\w_]+\s*=>\s*{) # ARROW FUNCTIONS
|
|
1280
|
+
|(\b(true|false|null|undefined)\b) # JAVASCRIPT LITERALS
|
|
1281
|
+
|(\b(document|window|console)\.) # BROWSER OBJECTS
|
|
1282
|
+
|(\b[\w_]+\.(forEach|map|filter|reduce)\() # ARRAY METHODS
|
|
1283
|
+
|(/[^/\n\r]*?/[gimsuy]*) # REGULAR EXPRESSIONS
|
|
1284
|
+
|(===|!==|\+\+|--|\|\||&&) # JAVASCRIPT-SPECIFIC OPERATORS
|
|
1285
|
+
|(\bclass\s+[\w_]+) # CLASS DECLARATIONS
|
|
1286
|
+
|(\bimport\s+.*?from\s+) # IMPORT STATEMENTS
|
|
1287
|
+
|(\bexport\s+(default\s+)?) # EXPORT STATEMENTS
|
|
1288
|
+
|(\basync\s+function) # ASYNC FUNCTIONS
|
|
1289
|
+
|(\bawait\s+) # AWAIT KEYWORD
|
|
1290
|
+
|(\btry\s*{) # TRY-CATCH BLOCKS
|
|
1291
|
+
|(\bcatch\s*\()
|
|
1292
|
+
|(\bfinally\s*{)
|
|
1293
|
+
|(\byield\s+) # GENERATOR FUNCTIONS
|
|
1294
|
+
|(\[.*?\]\s*=) # DESTRUCTURING ASSIGNMENT
|
|
1295
|
+
|(\.\.\.) # SPREAD OPERATOR
|
|
1296
|
+
|(==|!=|>=|<=|>|<) # COMPARISON OPERATORS
|
|
1297
|
+
|(\+=|-=|\*=|/=|%=|\*\*=) # COMPOUND ASSIGNMENT OPERATORS
|
|
1298
|
+
|(\+|-|\*|/|%|\*\*) # ARITHMETIC OPERATORS
|
|
1299
|
+
|(&|\||\^|~|<<|>>|>>>) # BITWISE OPERATORS
|
|
1300
|
+
|(\?|:) # TERNARY OPERATOR
|
|
1301
|
+
|(\bin\b) # IN OPERATOR
|
|
1302
|
+
|(\binstanceof\b) # INSTANCEOF OPERATOR
|
|
1303
|
+
|(\bdelete\b) # DELETE OPERATOR
|
|
1304
|
+
|(\btypeof\b) # TYPEOF OPERATOR
|
|
1305
|
+
|(\bvoid\b) # VOID OPERATOR
|
|
1306
|
+
)[\s\S]*$'''), rx.VERBOSE | rx.IGNORECASE)
|
|
1307
|
+
return bool(js_pattern.fullmatch(code))
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
################################################## REGEX STRING-TEMPLATES ##################################################
|
|
1312
|
+
|
|
1313
|
+
class Regex:
|
|
1314
|
+
"""Big regex code presets.\n
|
|
1315
|
+
-----------------------------------------------------
|
|
1316
|
+
`brackets` match everything inside brackets<br>
|
|
1317
|
+
`outside_strings` match the pattern but not inside strings<br>
|
|
1318
|
+
`all_except` match everything except a certain pattern<br>
|
|
1319
|
+
`func_call` match a function call
|
|
1320
|
+
"""
|
|
1321
|
+
|
|
1322
|
+
@staticmethod
|
|
1323
|
+
def quotes() -> str:
|
|
1324
|
+
"""Match everything inside quotes. (Strings)\n
|
|
1325
|
+
------------------------------------------------------------------------------------
|
|
1326
|
+
Will create two named groups:<br>
|
|
1327
|
+
**`quote`** the quote type (single or double)<br>
|
|
1328
|
+
**`string`** everything inside the found quote pair\n
|
|
1329
|
+
------------------------------------------------------------------------------------
|
|
1330
|
+
**Attention:** Requires non standard library `regex` not standard library `re`!"""
|
|
1331
|
+
return r'(?P<quote>[\'"])(?P<string>(?:\\.|(?!\g<quote>).)*?)\g<quote>'
|
|
1332
|
+
|
|
1333
|
+
@staticmethod
|
|
1334
|
+
def brackets(bracket1:str = '(', bracket2:str = ')', is_group:bool = False) -> str:
|
|
1335
|
+
"""Match everything inside brackets, including other nested brackets.\n
|
|
1336
|
+
------------------------------------------------------------------------------------
|
|
1337
|
+
**Attention:** Requires non standard library `regex` not standard library `re`!"""
|
|
1338
|
+
g, b1, b2 = '' if is_group else '?:', re.escape(bracket1) if len(bracket1) == 1 else bracket1, re.escape(bracket2) if len(bracket2) == 1 else bracket2
|
|
1339
|
+
return rf'{b1}\s*({g}(?:[^{b1}{b2}"\']|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|{b1}(?:[^{b1}{b2}"\']|"(?:\\.|[^"\\])*"|\'(?:\\.|[^\'\\])*\'|(?R))*{b2})*)\s*{b2}'
|
|
1340
|
+
|
|
1341
|
+
@staticmethod
|
|
1342
|
+
def outside_strings(pattern:str = r'.*') -> str:
|
|
1343
|
+
"""Match the `pattern` only when it is not found inside a string (`'...'` or `"..."`)."""
|
|
1344
|
+
return rf'(?<!["\'])(?:{pattern})(?!["\'])'
|
|
1345
|
+
|
|
1346
|
+
@staticmethod
|
|
1347
|
+
def all_except(disallowed_pattern:str, ignore_pattern:str = '', is_group:bool = False) -> str:
|
|
1348
|
+
"""Match everything except `disallowed_pattern`, unless the `disallowed_pattern` is found inside a string (`'...'` or `"..."`).\n
|
|
1349
|
+
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
|
|
1350
|
+
The `ignore_pattern` is just always ignored. For example if `disallowed_pattern` is `>` and `ignore_pattern` is `->`, the `->`-arrows will be allowed, even though they have `>` in them.<br>
|
|
1351
|
+
If `is_group` is `True`, you will be able to reference the matched content as a group (e.g. <code>match.group(<i>int</i>)</code> or <code>r'\\<i>int</i>'</code>)."""
|
|
1352
|
+
return rf'({'' if is_group else '?:'}(?:(?!{ignore_pattern}).)*(?:(?!{Regex.outside_strings(disallowed_pattern)}).)*)'
|
|
1353
|
+
|
|
1354
|
+
@staticmethod
|
|
1355
|
+
def func_call(func_name:str = None) -> str:
|
|
1356
|
+
"""Match a function call<br>
|
|
1357
|
+
**`1`** function name<br>
|
|
1358
|
+
**`2`** the function's arguments\n
|
|
1359
|
+
If no `func_name` is given, it will match any function call.\n
|
|
1360
|
+
------------------------------------------------------------------------------------
|
|
1361
|
+
**Attention:** Requires non standard library `regex` not standard library `re`!"""
|
|
1362
|
+
return rf'(?<=\b)({func_name if func_name else r'[\w_]+'})\s*{Regex.brackets('(', ')', is_group=True)}'
|
|
1363
|
+
|
|
1364
|
+
@staticmethod
|
|
1365
|
+
def rgb_str(fix_sep:str = ',', allow_alpha:bool = False) -> str:
|
|
1366
|
+
"""Match an RGB color inside a string.\n
|
|
1367
|
+
---------------------------------------------------------------------------------
|
|
1368
|
+
The RGB color can be in the formats (for `fix_sep = ','`):<br>
|
|
1369
|
+
`rgb(r, g, b)`<br>
|
|
1370
|
+
`rgb(r, g, b, a)` (if `allow_alpha = True`)<br>
|
|
1371
|
+
`(r, g, b)`<br>
|
|
1372
|
+
`(r, g, b, a)` (if `allow_alpha = True`)<br>
|
|
1373
|
+
`r, g, b`<br>
|
|
1374
|
+
`r, g, b, a` (if `allow_alpha = True`)\n
|
|
1375
|
+
---------------------------------------------------------------------------------
|
|
1376
|
+
If the `fix_sep` is set to nothing, and char which is not a letter or number<br>
|
|
1377
|
+
can be used to separate the RGB values, including just a space."""
|
|
1378
|
+
if fix_sep in ['', None]: fix_sep = r'[^0-9A-Z]'
|
|
1379
|
+
else: fix_sep = re.escape(fix_sep)
|
|
1380
|
+
rgb_part = rf'''((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))
|
|
1381
|
+
(?:\s*{fix_sep}\s*)((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))
|
|
1382
|
+
(?:\s*{fix_sep}\s*)((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))'''
|
|
1383
|
+
return rf'''(?ix)
|
|
1384
|
+
(?:rgb)?\s*(?:\(?\s*{rgb_part}
|
|
1385
|
+
(?:(?:\s*{fix_sep}\s*)((?:0*(?:0?\.[0-9]+|1\.0+|[0-9]+\.[0-9]+|[0-9]+))))?
|
|
1386
|
+
\s*\)?)''' if allow_alpha else rf'(?ix)(?:rgb)?\s*(?:\(?\s*{rgb_part}\s*\)?)'
|